Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc49a8286d | ||
|
|
21db863958 | ||
|
|
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 | ||
|
|
c2a381bb63 | ||
|
|
c0487e3af0 | ||
|
|
37fc17cf9c | ||
|
|
20b618685f | ||
|
|
72e29b1882 | ||
|
|
241fecfb2c | ||
|
|
723591cbce | ||
|
|
69af4c1e3b | ||
|
|
152908fec6 | ||
|
|
fbc5a41d12 | ||
|
|
7f801d27f5 | ||
|
|
10e2e9dc04 | ||
|
|
8734089223 | ||
|
|
aaa876e593 | ||
|
|
d981392593 | ||
|
|
f25552eef2 | ||
|
|
4276d7b915 | ||
|
|
f6ba85b18f | ||
|
|
919845518c | ||
|
|
fe935235e8 | ||
|
|
e22ab8cc49 | ||
|
|
e53e238ca6 | ||
|
|
309c26f209 | ||
|
|
01573faced | ||
|
|
689f1c01fd | ||
|
|
cb8a09b571 | ||
|
|
7f87e63b7c | ||
|
|
95641036cc | ||
|
|
505d635c66 | ||
|
|
9f0e4b0ac3 | ||
|
|
2cbd98df1a | ||
|
|
e618e906ef | ||
|
|
96e574e6c7 | ||
|
|
6a8aa37198 | ||
|
|
aa0c17f521 | ||
|
|
464eeeb2b1 | ||
|
|
daac5d7392 | ||
|
|
c85748b3aa | ||
|
|
216b5b437a | ||
|
|
78e5059759 | ||
|
|
2f635db2b4 | ||
|
|
709a2f2335 | ||
|
|
58973473c9 | ||
|
|
b68d95d217 | ||
|
|
07ca1cd9e1 | ||
|
|
5dcac25c13 | ||
|
|
fc47606b1c | ||
|
|
65694ad378 | ||
|
|
092dff25a5 | ||
|
|
56c21bcf03 | ||
|
|
208f762c18 | ||
|
|
4d035213b5 | ||
|
|
2b15280d03 | ||
|
|
723152cdad | ||
|
|
fe0e49a134 | ||
|
|
e5a272b682 | ||
|
|
8b3064ffab | ||
|
|
423c3e2a4b | ||
|
|
f4c497681f | ||
|
|
40e4a9e45e | ||
|
|
68810369bd | ||
|
|
2b7eff14c4 | ||
|
|
7d48e8f607 | ||
|
|
be4ecef20f | ||
|
|
da5b82fcdb | ||
|
|
f253e3fcb1 | ||
|
|
59223c0da4 | ||
|
|
32a16ff50d |
+28
-1
@@ -10,7 +10,34 @@
|
||||
"Bash(./bin/fuj help *)",
|
||||
"Bash(./bin/fuj version *)",
|
||||
"Bash(make go-test *)",
|
||||
"Bash(make go-lint *)"
|
||||
"Bash(make go-lint *)",
|
||||
"Bash(tea pr create --title 'fix\\(go\\): pass raw value to FormatDate so numeric dates format' --description ' *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(go build *)",
|
||||
"Bash(go test *)",
|
||||
"Bash(make parity *)",
|
||||
"Bash(tea pr create --title 'fix\\(go\\): accept single-digit day/month in attendance date headers' --description ' *)",
|
||||
"Bash(lsof -nP -iTCP:8080 -sTCP:LISTEN)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management checkout -b fix/period-selector-restore)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management log --oneline -20)",
|
||||
"Bash(curl -s -o /tmp/fio-transparent.html \"https://ib.fio.cz/ib/transparent?a=2800359168\")",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(grep -oE '[0-9]{1,2}\\\\.[0-9]{1,2}\\\\.[0-9]{2,4}' /tmp/fio-transparent.html | head -20)",
|
||||
"Read(//private/tmp/**)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management status)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat HEAD)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management log -8 --oneline)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management tag --sort=-v:refname)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch --show-current)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management show --stat --format='%H %s%n%nbranch?: %d' 0.33)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management for-each-ref --format='%\\(refname:short\\) %\\(objectname:short\\) %\\(subject\\)' refs/tags)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch -r --contains 0.33)",
|
||||
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat main HEAD -- Makefile scripts/fio_utils.py scripts/sync_fio_to_sheets.py CHANGELOG.md)",
|
||||
"Bash(cp /Users/jan.novak/.claude/plans/in-python-app-i-m-distributed-muffin.md /Users/jan.novak/srv/personal/fuj-management/docs/plans/2026-06-08-1110-junior-expected-fix.md)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/Users/jan.novak/srv/personal/fuj-management/docs/plans"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ jobs:
|
||||
TAG=${{ inputs.tag }}
|
||||
fi
|
||||
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
||||
docker build -f build/Dockerfile \
|
||||
docker build -f legacy/build/Dockerfile \
|
||||
--build-arg GIT_TAG=$TAG \
|
||||
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||
-t $IMAGE .
|
||||
-t $IMAGE legacy/
|
||||
docker push $IMAGE
|
||||
|
||||
build-go:
|
||||
|
||||
@@ -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/
|
||||
@@ -7,3 +7,4 @@ tmp/
|
||||
|
||||
# go build output
|
||||
bin/
|
||||
go/parity
|
||||
|
||||
Vendored
+5
-3
@@ -2,14 +2,16 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Flask",
|
||||
"name": "Python Debugger: Flask (legacy)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"python": "${workspaceFolder}/.venv/bin/python",
|
||||
"python": "${workspaceFolder}/legacy/.venv/bin/python",
|
||||
"cwd": "${workspaceFolder}/legacy",
|
||||
"env": {
|
||||
"FLASK_APP": "app.py",
|
||||
"FLASK_DEBUG": "1"
|
||||
"FLASK_DEBUG": "1",
|
||||
"CREDENTIALS_PATH": "${workspaceFolder}/.secret/fuj-management-bot-credentials.json"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
|
||||
+247
@@ -1,5 +1,252 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-10-08 21:27 CEST — feat(fees): junior rate for 2026-08 is 0
|
||||
|
||||
- Junior monthly rate for 2026-08: 500 → 0 CZK, same as adults.
|
||||
- A single practice in a 0-rate month is now 0 instead of `?`, since there is nothing to review in a free month. Other months are unchanged.
|
||||
- Mirrored in `go/internal/domain/fees/junior.go` and `legacy/scripts/attendance.py`; two new parity fixtures for `calculate_junior_fee`.
|
||||
|
||||
## 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: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added).
|
||||
- Mirrored in both `scripts/attendance.py` and `go/internal/domain/fees/fees.go`.
|
||||
|
||||
## 2026-05-24 21:42 CEST — feat: multi-account Fio sync + switch QR default to 2502035405/2010
|
||||
|
||||
- Added second bank account `2502035405/2010` (IBAN `CZ0820100000002502035405`) to sync.
|
||||
- Both accounts are fetched on every sync; dedup by existing sync_id keeps the payments sheet clean.
|
||||
- QR codes now default to the new account (`CZ0820100000002502035405`).
|
||||
- Go: `config.go` gains hardcoded `Accounts`/`LoadedAccount` slice; `Config.BankAccount` renamed to `Config.QRAccount`; `FioAPIToken` removed (tokens are per-account via `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
|
||||
- Go: `SyncToSheets` now accepts `[]fio.Client`; new `TestSyncToSheets_MultiAccount` test.
|
||||
- Python: `config.py` gains `ACCOUNTS` / `LOADED_ACCOUNTS`; `fio_utils.py` adds `fetch_transactions_for` and `fetch_transactions_all`; `sync_fio_to_sheets.py` uses `fetch_transactions_all`.
|
||||
- Key files: `go/internal/config/config.go`, `go/internal/services/banksync/sync.go`, `go/cmd/fuj/main.go`, `scripts/config.py`, `scripts/fio_utils.py`, `scripts/sync_fio_to_sheets.py`.
|
||||
|
||||
## 2026-05-11 23:58 CEST — fix(reconcile): fill earliest month deficit first in multi-month allocations
|
||||
|
||||
- Multi-month payment allocation now fills the earliest in-window deficit first and spills
|
||||
any remainder to later months, accounting for prior transactions' contributions to each month.
|
||||
Previously a single transaction was split proportionally to each month's total expected fee,
|
||||
ignoring what earlier transactions had already paid — surfaced by Matyáš Thér's 200+550 case
|
||||
showing 566/183 instead of 500/250.
|
||||
- Files: `scripts/match_payments.py`, `go/internal/domain/reconcile/reconcile.go`, tests, parity fixtures.
|
||||
|
||||
## 2026-05-11 22:56 CEST — fix(python): parse Fio 2-digit-year dates + add `make sync-debug` dry-run tool
|
||||
|
||||
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
|
||||
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
|
||||
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
|
||||
## 2026-05-08 15:24 CEST — feat(go): M6.7 — single-binary embed verification
|
||||
|
||||
- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
|
||||
- Added `go/internal/web/assets_test.go` with two tests: `TestEmbedCompleteness` (walks disk vs embed.FS to catch forgotten files) and `TestStaticAssetsServed` (hits `/static/css/app.css` and all JS files through the mux, asserts 200 + Content-Type + non-empty body + 404 for unknown paths).
|
||||
- Closes M6; single binary confirmed self-contained with no adjacent `templates/` or `static/` required at runtime.
|
||||
- Key files: `go/internal/web/assets_test.go` (new).
|
||||
|
||||
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
|
||||
|
||||
- Restored the Python `showPayQR` in-page modal UX that was lost in M6.6 (Pay buttons were navigating the tab to the raw `/qr` PNG).
|
||||
- Replaced `<a href="{{qrHref ...}}">Pay</a>` with `<button data-name|amount|month|raw-month>` on `/adults` and `/juniors`; click is handled by a new `static/js/payment-qr.js` IIFE module that opens `#qrModal` with title, account, amount, message, and the QR image.
|
||||
- Added `#qrModal` markup to both templates; CSS `display:none` / `.active{display:flex}` rules added (content rules were already present from M6.1). `Esc`, `[close]`, and outside-click all dismiss; coexists with the M6.5 member-detail modal.
|
||||
- Removed the now-dead `qrHref` / `qrHrefAll` template helpers from `render.go`.
|
||||
- Markup tests in `html_handler_test.go` assert modal IDs, script tag, `data-bank-account`, and that no bare `href="/qr"` links remain.
|
||||
- Key files: `go/internal/web/static/js/payment-qr.js`, `go/internal/web/templates/adults.tmpl`, `go/internal/web/templates/juniors.tmpl`, `go/internal/web/render.go`, `go/internal/web/static/css/app.css`.
|
||||
|
||||
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
|
||||
|
||||
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
|
||||
- Implemented `GET /sync-bank`: runs Fio sync → infer payments → cache flush, captures output into `sync.tmpl` page with success/error banner.
|
||||
- Implemented `GET /flush-cache` + `POST /flush-cache`: form + action that deletes cache files and shows deleted count.
|
||||
- Added `GET /version` as a JSON alias of `GET /api/version` (Python parity).
|
||||
- Added `FlushCache() (int, error)` to `membership.Sources` interface; implemented on `realSources` via `cache.FileCache.Flush()`.
|
||||
- Introduced `web.ActionHandlers{BankSync}` — closure-based dep injection for sync, constructed in `serverCmd` with fio + sheets clients.
|
||||
- New dependency: `github.com/skip2/go-qrcode`.
|
||||
- Key files: `go/internal/web/qr.go`, `go/internal/web/html_handler.go`, `go/internal/web/server.go`, `go/internal/services/membership/loader.go`, `go/internal/web/templates/sync.tmpl`, `go/internal/web/templates/flush_cache.tmpl`.
|
||||
|
||||
## 2026-05-08 13:19 CEST — feat(go): M6.5 — member-detail modal JS module
|
||||
|
||||
- Added `static/js/member-detail.js`: fetches `/api/adults` or `/api/juniors` once on page load, caches the response, renders a per-member detail modal on `[i]` row click.
|
||||
- Modal sections: status-per-month table, fee exceptions, other transactions, matched payment history, toggleable raw-payments debug view.
|
||||
- Keyboard nav: `Esc` closes, `↑`/`↓` walk visible (name-filtered) rows; click outside modal content closes it.
|
||||
- Added `[i]` info icon and `#memberModal` markup to `adults.tmpl` and `juniors.tmpl`; all CSS was already in place from M6.1.
|
||||
- Added `TestModalMarkup` assertions to `html_handler_test.go`.
|
||||
|
||||
## 2026-05-08 12:48 CEST — feat(go): M6.4 — Go-native /payments page
|
||||
|
||||
- Extracted `AssemblePayments(ctx)` from the inlined JSON handler body, following the M6.2/M6.3 `AssembleAdults`/`AssembleJuniors` pattern.
|
||||
- Added `PaymentsPageData` to `render.go`; wired `HTMLHandler.ServePayments` to call `AssemblePayments` and render the new template.
|
||||
- Replaced the "Coming in M6.4" placeholder in `payments.tmpl` with the full grouped-by-person ledger: alphabetical `<h2>` member blocks, each with a `txn-table` (Date / Amount / Purpose / Bank Message), newest-first rows, `"Unmatched / Unknown"` bucket.
|
||||
- Appended ledger CSS classes to `app.css` (`.ledger-container`, `.member-block`, `.txn-table`, `.txn-{date,amount,purpose,message}`, `tr:hover`).
|
||||
- Added `TestPaymentsPage` markup test. No filters, no JS — matches the Python page.
|
||||
|
||||
## 2026-05-08 11:25 CEST — feat(go): M6.3 — Go-native /juniors page
|
||||
|
||||
- Extracted `AssembleJuniors(ctx)` from the JSON handler so HTML and JSON share one view-model path (mirrors `AssembleAdults`).
|
||||
- Added `JuniorsPageData` to `render.go`; wired `HTMLHandler.ServeJuniors` to call `AssembleJuniors` and render the new template.
|
||||
- Replaced the 4-line "Coming in M6.3" placeholder in `juniors.tmpl` with the full template: member table, name/month-range filters, totals row, Credits + Debts sections, Pay / Pay All links to `/qr`. No Unmatched section (parity with Python).
|
||||
- J/A attendance breakdown and `"?"` sentinel rendered via `MonthCell.Text` produced by `buildJuniorMemberRow` — no extra template logic.
|
||||
- `make parity` reports 3/3 routes OK; all Go tests pass.
|
||||
|
||||
## 2026-05-08 11:11 CEST — fix: period selector showed only Dec 2025+ on adults
|
||||
|
||||
- Restored the `2025-09 → 2025-10` adult merge in `scripts/attendance.py` and `go/internal/services/membership/sources.go` (commented-out by `1257f0d`); the `2025-12 → 2026-01` mapping stays disabled per product decision (Dec and Jan are billed separately for adults).
|
||||
- Dropped the `defaultFrom = maxMonthIdx − 4` JS auto-default in `templates/adults.html` and `templates/juniors.html` (introduced by `7774301`); the From-selector now starts at the oldest available month so all non-future periods render on first load. Future-month removal is preserved.
|
||||
- `go/internal/services/membership/sources_test.go`: `TestLoadAdults` / `TestLoadAdults_Fee` now assert that Sep dates land in the merged `2025-10` bucket.
|
||||
- **Independent of these code changes**: the live adults attendance Google Sheet header had been pruned to start at `02.12.2025` (Sep/Oct/Nov 2025 columns deleted); restoring those columns from Sheets version history is required to actually see those periods on the dashboard.
|
||||
|
||||
## 2026-05-08 10:15 CEST — fix(go): adults template — use lifted CSS classes for visual parity
|
||||
|
||||
- Use existing `.balance-pos` / `.balance-neg` (drop invented `balance-cell` / `balance-negative`); Pay-All button now lives inside the balance cell with `position: relative` (matches Python; no separate trailing column).
|
||||
- Credits / Debts / Unmatched sections rewritten from `<ul>` / `<table>` to `<div class="list-container"><div class="list-item">…` / `<div class="unmatched-row">…` so the lifted CSS actually applies.
|
||||
- Section headings get the descriptive Python text: "Credits (Advance Payments / Surplus)", "Debts (Missing Payments)".
|
||||
- Source links moved from page bottom to a `<div class="description">` block under the h1, matching Python's "Source: Attendance Sheet | Payments Ledger".
|
||||
- Total row uses `cell-{{Status}}` plus the small "received / expected" caption span and Python's inline-styled bold/dark background.
|
||||
- Drop the redundant `class="cell"` wrapper; debts amount turns red via inline style; balance value drops the trailing "CZK".
|
||||
|
||||
## 2026-05-08 01:09 CEST — feat(go): M6.2 — adults page (table, filters, Pay buttons)
|
||||
|
||||
- `go/internal/web/api/handler.go`: extracted `ServeAdults` body into `AssembleAdults(ctx)` — shared by the JSON API route and the new HTML handler.
|
||||
- `go/internal/web/render.go`: added `AdultsPageData` view model (`PageData` + `api.AdultsResponse` + `Error`); `tmplFuncs` with `qrHref` / `qrHrefAll` (URL-encode QR Platba params, convert YYYY-MM → MM/YYYY).
|
||||
- `go/internal/web/html_handler.go`: `HTMLHandler` gains `*api.Handler`; `ServeAdults` loads real reconcile data and renders the full adults page.
|
||||
- `go/internal/web/templates/adults.tmpl`: full table (per-member rows, per-cell status classes, `data-month-idx`, Pay button hrefs to `/qr`), totals row, credits/debts/unmatched sections, filter controls, sheet links.
|
||||
- `go/internal/web/static/js/filters.js`: name filter (NFD-normalize) + month-range hide/show by `data-month-idx`; future months hidden by default.
|
||||
|
||||
## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS
|
||||
|
||||
- `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache).
|
||||
- `go/internal/web/static/css/app.css`: terminal-green-on-black theme extracted once from Python `templates/adults.html` — shared by all Go HTML pages via `<link>`.
|
||||
- `go/internal/web/assets.go`: `//go:embed templates static` for single-binary deployment.
|
||||
- `go/internal/web/render.go`: `Renderer` parses a fresh `*template.Template` per page at startup; `Render(w, name, data)` executes the "base" template block.
|
||||
- `go/internal/web/html_handler.go`: `HTMLHandler` with one method per route (`ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeSync`, `ServeFlushCache`).
|
||||
- `go/internal/web/server.go`: drops `helloHandler`; `GET /{$}` now redirects to `/adults`; HTML + `/static/` routes registered alongside the existing `/api/*` routes.
|
||||
- `go/internal/web/html_handler_test.go`: smoke test — each route returns 200 `text/html` with exactly one `class="active"` on the matching nav link.
|
||||
|
||||
## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky
|
||||
|
||||
- `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`.
|
||||
- `scripts/views.py`: junior month cell `"?"` text is now sticky across exception overrides. Previously `reconcile` replaced `expected` with the exception amount before the view builder ran, silently turning `"?"` into `"-"` when the override was 0. Fixed by deriving `is_unknown` from `original_expected == "?"` instead of `expected == "?"`. Also aligned tooltip guard: only show Received/Expected for non-unknown months (or when paid > 0), matching Go's `!md.IsUnknown` condition.
|
||||
|
||||
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
|
||||
|
||||
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person` → `_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
|
||||
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
|
||||
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
|
||||
|
||||
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
|
||||
|
||||
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
|
||||
- `sources_test.go::TestParseDates_SingleDigitDayMonth` added as a regression guard covering both Czech and US format flavours with and without leading zeros.
|
||||
|
||||
## 2026-05-07 23:17 CEST — fix(go): pass raw value to FormatDate so numeric serial-day dates format
|
||||
|
||||
- `go/internal/services/membership/sources.go`: transaction-row parser now passes `row[idxDate]` directly to `matching.FormatDate` (via a new `getRaw` helper) instead of stringifying first via `getVal`. The Sheets API returns numeric serial-day values as `float64` for date-formatted cells; pre-stringifying them defeated `FormatDate`'s `case float64:` dispatch, causing all numeric dates to leak through as `"46147"` style strings instead of `"2026-05-05"`.
|
||||
- Surfaced by `make parity` (M5.4): every `transactions[].date` field on `/api/adults` and `/api/juniors` differed between Python and Go.
|
||||
- `sources_test.go::TestLoadTransactions` extended with a numeric-serial-day row covering the regression.
|
||||
|
||||
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
|
||||
|
||||
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
|
||||
- Why: both backends used `tmp/<key>_cache.json` with the same keys (`attendance_regular`, `attendance_juniors`, `payments_transactions`, `exceptions_dict`) but different shapes — Python caches post-processed view-model tuples, Go caches raw rows. Whichever wrote last poisoned the cache; running both in parallel produced `ValueError: too many values to unpack (expected 2, got 68)` on Python's `/adults` after the Go server populated `attendance_regular_cache.json` with raw CSV rows.
|
||||
- After upgrading: stop the Go server, hit `/flush-cache` on the Python side once (rewrites `tmp/*.json` with correct shapes), then restart `make web-go` — it will use `tmp/go/` going forward. Required for the M5.4 `make parity` workflow which assumes both backends run side-by-side.
|
||||
|
||||
## 2026-05-07 22:55 CEST — feat(go): M5.4 — parity diff binary + `make parity`
|
||||
|
||||
- `go/cmd/parity/main.go`: new standalone binary that GETs `/api/adults`, `/api/juniors`, `/api/payments` from both Python (:5001) and Go (:8080) backends, scrubs an allowlist (`render_time.total`), and prints `cmp.Diff` for any remaining differences. Exits 0 on full match, 1 on diffs, 2 on fetch/parse errors — CI-friendly for M7.2. `/api/version` is excluded by design (returns binary identity — tag/commit/build_date — which differs between independently built backends); still accessible via `make parity ARGS="-route /api/version"`.
|
||||
- `go/cmd/parity/scrub_test.go`: 4 unit tests covering top-level delete, nested delete, missing path, and non-map parent.
|
||||
- `go/go.mod`: `github.com/google/go-cmp` promoted to direct dependency.
|
||||
- `Makefile`: `parity` target added (`.PHONY`, help, `cd go && go run ./cmd/parity`).
|
||||
- `docs/plans/2026-05-07-2254-m5-4-parity-binary.md`: plan archived.
|
||||
|
||||
## 2026-05-07 22:37 CEST — feat(py): M5.3 — Python /api/* shadow endpoints
|
||||
|
||||
- `app.py`: four new JSON routes (`/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`) mirroring the Go `/api/*` handlers; `_unwrap_view_model_for_api()` helper expands pre-serialised JSON strings and renames `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` to match Go wire contract.
|
||||
- `tests/test_app.py`: four new smoke tests asserting top-level key sets and that unwrapped fields are objects (not strings).
|
||||
|
||||
## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
|
||||
|
||||
- `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`.
|
||||
- `web/api/build_{adults,juniors,payments,common}.go`: ports of `scripts/views.py` view-model builders; `buildJuniorMemberRow` handles `"?"` sentinel, `:NJ,MA` breakdown, unknown-month skip.
|
||||
- Extended `reconcile.FeeData`/`MonthData` with `IsUnknown`, `JuniorAttendance`, `AdultAttendance`; `Transaction` with `ManualFix`, `VS`, `BankID`, `SyncID`.
|
||||
- `sources.go` exports `AdultMergedMonths`/`JuniorMergedMonths`; parses new FeeData and transaction columns.
|
||||
- `web/server.go` + `cmd/fuj/main.go` wired to register `/api/*` routes.
|
||||
- PR #17.
|
||||
|
||||
## 2026-05-07 17:37 CEST — feat(go): M5.1 — /api/* wire types + JSON Schemas
|
||||
|
||||
- New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys.
|
||||
- `Expected{Value int; Unknown bool}` custom `MarshalJSON` emits integer or `"?"` for junior single-attendance months.
|
||||
- `schemagen_test.go` golden-tests four JSON Schemas committed to `go/tests/fixtures/api-schema/`. `JSONSchema()` on `Expected` lives in the test file — production binary has no jsonschema dep.
|
||||
- PR #16.
|
||||
|
||||
## 2026-05-07 15:26 CEST — refactor(app): extract view-model builders into scripts/views.py
|
||||
|
||||
- Pulled ~350 lines of inline per-row computation out of `adults_view`, `juniors_view`, and `payments` into three pure functions in `scripts/views.py`: `build_adults_view_model`, `build_juniors_view_model`, `build_payments_view_model`.
|
||||
- Moved `get_month_labels`, `group_payments_by_person`, `adapt_junior_members` from `app.py` to `scripts/views.py`. Route handlers now ~25 lines each.
|
||||
- Hotfixed missing `import re` that caused 500 on `/qr` after the refactor.
|
||||
- No behaviour change; all 27 tests pass. Prep for `/api/*` shadow endpoints (M5).
|
||||
|
||||
## 2026-05-07 14:13 CEST — feat(go): --print-fio-table + Fio debug logging + date parser fix
|
||||
|
||||
- Added `--print-fio-table` flag to `fuj sync --dry-run`: prints an aligned table of every Fio transaction in the window with `STATUS=NEW/DUP`, using `text/tabwriter`. Key files: `go/internal/services/banksync/fio_table.go`, `sync.go`, `cmd/fuj/main.go`.
|
||||
|
||||
@@ -4,41 +4,89 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Status
|
||||
|
||||
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
|
||||
Financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
|
||||
|
||||
- **Go is the primary implementation** (`go/`). It is the version that is built, deployed and developed first.
|
||||
- **Python is legacy** (`legacy/`). The project was originally developed in Python (Flask); that version is kept runnable and in sync, but is considered legacy from now on.
|
||||
|
||||
## Dual implementation rule
|
||||
|
||||
Until the Python version is retired, **every new feature and every behaviour change must be implemented in both Go and Python**:
|
||||
|
||||
1. Implement and test in Go first (`go/`).
|
||||
2. Mirror the change in the legacy Python code (`legacy/`), including its tests.
|
||||
3. Keep the two in parity: shared constants (fee rates, merged months, bank accounts, sheet IDs) must match on both sides, and the JSON API contract (`/api/adults`, `/api/juniors`, `/api/payments`) must stay identical. The HTML frontends are allowed to diverge.
|
||||
4. If a pure function's behaviour changes, add or update seeds in `legacy/scripts/_fixture_seeds.py`, run `make capture-fixtures`, and make sure `make go-test-all` passes. With both servers running, `make parity` diffs the live JSON APIs.
|
||||
|
||||
Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).
|
||||
|
||||
### Deliberately Go-only features
|
||||
|
||||
These exist only in the Go app on purpose. Do not port them to the legacy Python app, which is being phased out:
|
||||
|
||||
- **Scheduled bank sync** — the Go server runs sync → infer → flush-cache by itself at the times in `SYNC_SCHEDULE`. Python's multi-worker gunicorn setup would need a cross-process lock for this, which is not worth building in code that is going away. In Python the sync still runs only from `/sync-bank` or the CLI.
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
||||
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
|
||||
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `go/internal/config/config.go` (mirrored in `legacy/scripts/config.py`). Domain-specific constants (fees, merged months) stay in their respective modules.
|
||||
|
||||
## Development Setup
|
||||
|
||||
This project uses `uv` for dependency management.
|
||||
Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`.
|
||||
|
||||
```bash
|
||||
uv venv && uv sync
|
||||
source .venv/bin/activate
|
||||
make go-build # builds bin/fuj
|
||||
```
|
||||
|
||||
Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically).
|
||||
Python (legacy) uses `uv`; the project file and virtualenv live in `legacy/`.
|
||||
|
||||
```bash
|
||||
make venv # uv sync --project legacy → legacy/.venv
|
||||
```
|
||||
|
||||
The Makefile sets `PYTHONPATH` (`legacy/scripts:legacy`) and `CREDENTIALS_PATH` (`.secret/fuj-management-bot-credentials.json` at the repo root) for the Python targets. Set both yourself when running Python scripts directly.
|
||||
|
||||
## Commands
|
||||
|
||||
Go (primary):
|
||||
|
||||
```bash
|
||||
make web # Start dashboard at http://localhost:5001
|
||||
make web-go # Build and start dashboard at http://localhost:8080
|
||||
make go-build # Build bin/fuj
|
||||
make go-test # Unit tests (go test -race ./...)
|
||||
make go-parity # Parity tests against the captured Python fixtures
|
||||
make go-test-all # Both of the above
|
||||
make go-lint # golangci-lint
|
||||
make go-run ARGS="fees" # Run a subcommand: server, fees, reconcile, sync, infer, version
|
||||
make go-sync-debug [DAYS=N] # Dry-run bank sync with debug logs
|
||||
```
|
||||
|
||||
Python (legacy):
|
||||
|
||||
```bash
|
||||
make web-py # Start dashboard at http://localhost:5001 (make web is an alias)
|
||||
make web-debug # Same with FLASK_DEBUG=1
|
||||
make test # Run all tests (unittest discover)
|
||||
make test-v # Tests with verbose output
|
||||
make fees # Print fee report from attendance sheet
|
||||
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
|
||||
make sync-year # Sync Fio transactions for the current year plus Nov-Dec of the previous one (YEAR=N to override)
|
||||
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
|
||||
make reconcile # Print balance report from Google Sheets data
|
||||
make image # Build Docker image
|
||||
make image # Build the legacy Python Docker image
|
||||
```
|
||||
|
||||
Cross-implementation:
|
||||
|
||||
```bash
|
||||
make capture-fixtures # Regenerate go/tests/fixtures from the Python implementation
|
||||
make parity # Diff /api/* between web-py (:5001) and web-go (:8080); both must be running
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
```bash
|
||||
PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route
|
||||
cd go && go test ./internal/domain/reconcile -run TestName
|
||||
cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -46,47 +94,77 @@ PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_ro
|
||||
### Data flow
|
||||
|
||||
```
|
||||
Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/
|
||||
Google Sheets (payments) ──► match_payments.py ──┘
|
||||
Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments)
|
||||
Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
|
||||
Google Sheets (payments) ──► payments loader ──┘
|
||||
Fio Bank API ──► bank sync ──► Google Sheets (payments)
|
||||
```
|
||||
|
||||
### Key modules
|
||||
### Repository layout
|
||||
|
||||
- `app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`
|
||||
- `scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
|
||||
- `scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
|
||||
- `scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `tmp/`.
|
||||
- `scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
|
||||
- `scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
|
||||
- `scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `BANK_ACCOUNT`, `CACHE_TTL_SECONDS`).
|
||||
- `go/` — primary implementation (single binary `fuj`, templates and static assets embedded).
|
||||
- `legacy/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`).
|
||||
- `docs/` — documentation; `docs/plans/` holds plans and task lists.
|
||||
- `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR.
|
||||
|
||||
### Key modules — Go (primary)
|
||||
|
||||
- `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`.
|
||||
- `go/cmd/parity` — diffs the JSON API of the Go and Python servers.
|
||||
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`, `SYNC_SCHEDULE`, `SYNC_TZ`).
|
||||
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
|
||||
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
|
||||
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference; `Runner` serialises manual and scheduled runs).
|
||||
- `go/internal/scheduler` — runs a job at fixed times of day; used by `fuj server` for the scheduled bank sync.
|
||||
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
|
||||
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
|
||||
|
||||
### Key modules — Python (legacy)
|
||||
|
||||
- `legacy/app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`, `/api/*`
|
||||
- `legacy/scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
|
||||
- `legacy/scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
|
||||
- `legacy/scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `legacy/tmp/`.
|
||||
- `legacy/scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
|
||||
- `legacy/scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
|
||||
- `legacy/scripts/views.py` — View models shared by the HTML routes and the JSON API.
|
||||
- `legacy/scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `CACHE_TTL_SECONDS`, `MONTHS_TO_SHOW`).
|
||||
- `legacy/scripts/capture_fixtures.py`, `scrub_fixtures.py`, `_fixture_seeds.py` — produce the scrubbed parity fixtures for Go.
|
||||
|
||||
### Member tiers
|
||||
|
||||
Tiers are set in column B of the attendance sheet:
|
||||
- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
|
||||
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 600 CZK for 2+ sessions; 200 CZK for exactly 1, capped at the month's rate)
|
||||
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
||||
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
||||
|
||||
### Fee calculation
|
||||
|
||||
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK)
|
||||
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 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; 0 in a 0-rate month), 2+ → monthly rate from `JuniorFeeMonthlyRate` / `JUNIOR_MONTHLY_RATE` (default 500 CZK)
|
||||
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
|
||||
|
||||
### Merged months
|
||||
|
||||
`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `attendance.py` map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
|
||||
`AdultMergedMonths` / `JuniorMergedMonths` in `go/internal/services/membership/sources.go` (`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `legacy/scripts/attendance.py`) map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
|
||||
|
||||
### Caching
|
||||
|
||||
`get_cached_data()` in `app.py` checks the Drive API `modifiedTime` before each request and serves a JSON file from `tmp/` when the sheet hasn't changed. Cache is warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST) or `flush_cache()`.
|
||||
Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST).
|
||||
|
||||
### Bank sync
|
||||
|
||||
- Manual: open `/sync-bank` (both apps) or run `fuj sync` + `fuj infer` / `make sync-year` + `make infer`.
|
||||
- Scheduled (Go server only): at each time in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`; `off` disables) read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup. Manual and scheduled runs share one lock and never overlap. The page footer shows the last run since start and the next one.
|
||||
- Both use the same window: 1 November of the previous year to 31 December of the current year; rows are de-duplicated by Sync ID.
|
||||
- The schedule runs per process: keep the deployment at one replica, and note that a local `make web-go` with real credentials also syncs at the scheduled time unless `SYNC_SCHEDULE=off`.
|
||||
|
||||
### Payments sheet columns
|
||||
|
||||
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
|
||||
|
||||
`Person` and `Purpose` are written by `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
|
||||
|
||||
|
||||
@@ -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 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)
|
||||
VENV := .venv
|
||||
PYTHON := $(VENV)/bin/python3
|
||||
CREDENTIALS := .secret/fuj-management-bot-credentials.json
|
||||
# Legacy Python implementation lives in legacy/ (Go in go/ is primary).
|
||||
LEGACY := legacy
|
||||
export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH)
|
||||
VENV := $(LEGACY)/.venv
|
||||
PYTHON := $(CURDIR)/$(VENV)/bin/python3
|
||||
CREDENTIALS := $(CURDIR)/.secret/fuj-management-bot-credentials.json
|
||||
# Both implementations read CREDENTIALS_PATH; the Python default would resolve under legacy/.
|
||||
export CREDENTIALS_PATH ?= $(CREDENTIALS)
|
||||
|
||||
GO_SRC := go
|
||||
GO_BIN := bin/fuj
|
||||
|
||||
$(PYTHON): .venv/.last_sync
|
||||
$(PYTHON): $(VENV)/.last_sync
|
||||
|
||||
.venv/.last_sync: pyproject.toml
|
||||
uv sync
|
||||
touch .venv/.last_sync
|
||||
$(VENV)/.last_sync: $(LEGACY)/pyproject.toml
|
||||
uv sync --project $(LEGACY)
|
||||
touch $(VENV)/.last_sync
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
||||
@echo " make match - Match Fio bank payments against expected attendance fees"
|
||||
@echo " make web - Start Python dashboard (alias for web-py, until M8)"
|
||||
@echo " make web-py - Start Python dashboard on :5001"
|
||||
@echo " make web-go - Build and start Go dashboard on :8080"
|
||||
@echo " make web - Start legacy Python dashboard (alias for web-py)"
|
||||
@echo " make web-py - Start legacy Python dashboard on :5001"
|
||||
@echo " make web-go - Build and start Go dashboard on :8080 (primary)"
|
||||
@echo " make web-debug - Start Python dashboard in debug mode"
|
||||
@echo " make go-build - Build Go binary to bin/fuj"
|
||||
@echo " make go-test - Run Go unit tests"
|
||||
@@ -29,11 +33,14 @@ help:
|
||||
@echo " make go-lint - Run golangci-lint on Go code"
|
||||
@echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)"
|
||||
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
|
||||
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
|
||||
@echo " make image - Build Python OCI container image"
|
||||
@echo " make run - Run the built Python Docker image locally"
|
||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
||||
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
||||
@echo " make sync-year [YEAR=N] - Sync Fio transactions for YEAR plus Nov-Dec of the year before (default: current year)"
|
||||
@echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)"
|
||||
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||
@echo " make venv - Sync virtual environment with pyproject.toml"
|
||||
@@ -42,21 +49,21 @@ help:
|
||||
@echo " make docs - Serve documentation in a browser"
|
||||
|
||||
venv:
|
||||
uv sync
|
||||
uv sync --project $(LEGACY)
|
||||
|
||||
fees: $(PYTHON)
|
||||
$(PYTHON) scripts/calculate_fees.py
|
||||
$(PYTHON) $(LEGACY)/scripts/calculate_fees.py
|
||||
|
||||
match: $(PYTHON)
|
||||
$(PYTHON) scripts/match_payments.py
|
||||
$(PYTHON) $(LEGACY)/scripts/match_payments.py
|
||||
|
||||
web: web-py
|
||||
|
||||
web-py: $(PYTHON)
|
||||
$(PYTHON) app.py
|
||||
$(PYTHON) $(LEGACY)/app.py
|
||||
|
||||
web-debug: $(PYTHON)
|
||||
FLASK_DEBUG=1 $(PYTHON) app.py
|
||||
FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py
|
||||
|
||||
go-build:
|
||||
cd $(GO_SRC) && go build -trimpath \
|
||||
@@ -80,10 +87,10 @@ capture-fixtures: $(PYTHON)
|
||||
infer_transaction_details format_date reconcile; do \
|
||||
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
|
||||
mkdir -p "$$dir"; \
|
||||
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \
|
||||
$(PYTHON) $(LEGACY)/scripts/capture_fixtures.py --func $$func --all \
|
||||
| while IFS= read -r line; do \
|
||||
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
|
||||
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
|
||||
echo "$$line" | $(PYTHON) $(LEGACY)/scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
|
||||
done; \
|
||||
echo " $$func done"; \
|
||||
done
|
||||
@@ -102,36 +109,46 @@ go-lint:
|
||||
web-go: go-build
|
||||
./$(GO_BIN) server
|
||||
|
||||
parity:
|
||||
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
|
||||
|
||||
image:
|
||||
docker build -t fuj-management:latest \
|
||||
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
|
||||
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||
-f build/Dockerfile .
|
||||
-f $(LEGACY)/build/Dockerfile $(LEGACY)
|
||||
|
||||
run:
|
||||
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
||||
|
||||
sync: $(PYTHON)
|
||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
|
||||
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS)
|
||||
|
||||
sync-2025: $(PYTHON)
|
||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date
|
||||
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2025-10-01 --to 2025-12-31 --sort-by-date
|
||||
|
||||
sync-2026: $(PYTHON)
|
||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||
|
||||
YEAR ?= $(shell date +%Y)
|
||||
sync-year: $(PYTHON)
|
||||
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from $(shell expr $(YEAR) - 1)-11-01 --to $(YEAR)-12-31 --sort-by-date
|
||||
|
||||
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
|
||||
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table
|
||||
|
||||
infer: $(PYTHON)
|
||||
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||
$(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||
|
||||
reconcile: ## Match payments against attendance
|
||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS)
|
||||
reconcile: $(PYTHON) ## Match payments against attendance
|
||||
$(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS)
|
||||
|
||||
test: $(PYTHON) ## Run web application tests
|
||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests
|
||||
cd $(LEGACY) && $(PYTHON) -m unittest discover tests
|
||||
|
||||
test-v: $(PYTHON) ## Run tests with verbose output
|
||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
|
||||
cd $(LEGACY) && $(PYTHON) -m unittest discover -v tests
|
||||
|
||||
docs: ## Serve documentation locally
|
||||
@echo "Starting documentation server at http://localhost:8000"
|
||||
|
||||
@@ -4,6 +4,8 @@ Welcome to the documentation for the FUJ Management application.
|
||||
|
||||
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
|
||||
|
||||
> **Go is the primary implementation** (`go/`). The project was originally developed in Python; that version now lives in `legacy/` and is considered legacy. New features are still built in both. Most guides below predate this change and describe the Python version with its old paths (`app.py`, `scripts/` are now `legacy/app.py`, `legacy/scripts/`).
|
||||
|
||||
## Navigation
|
||||
|
||||
Use the sidebar to explore the documentation:
|
||||
|
||||
@@ -182,7 +182,7 @@ fuj-management/
|
||||
│
|
||||
├── .gitea/workflows/
|
||||
│ ├── build.yaml # CI: build + push Docker image
|
||||
│ └── kubernetes-deploy.yaml # CD: deploy to K8s cluster
|
||||
│ └── gitops-update.yaml # CD: open image-update PR in home-kubernetes
|
||||
│
|
||||
├── .secret/ # (gitignored) API credentials
|
||||
├── docs/ # Project documentation
|
||||
|
||||
@@ -129,32 +129,27 @@ The project uses two Gitea Actions workflows:
|
||||
|
||||
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
|
||||
|
||||
#### 2. Deploy to Kubernetes (`kubernetes-deploy.yaml`)
|
||||
#### 2. GitOps image update (`gitops-update.yaml`)
|
||||
|
||||
**Triggers**:
|
||||
- Push to any branch
|
||||
- Manual dispatch
|
||||
- Completion of a successful "Build and Push" run
|
||||
- Manual dispatch (with `tag`, `dry_run` and `uh_cli_version` inputs)
|
||||
|
||||
**Steps**:
|
||||
1. Checkout code
|
||||
2. Install kubectl
|
||||
3. Retrieve Kanidm token from HashiCorp Vault:
|
||||
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
|
||||
- Fetch API token from `secret/data/gitea/gitea-ci`
|
||||
4. Exchange API token for K8s OIDC token via Kanidm:
|
||||
- POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
|
||||
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
|
||||
5. Configure kubectl with the OIDC token
|
||||
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
|
||||
1. Install `tea` and `uh-cli`
|
||||
2. Resolve the image tag of the Go image that was just built
|
||||
3. Open a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to that image
|
||||
|
||||
The cluster picks the change up once that PR is merged. CI does not talk to the cluster directly.
|
||||
|
||||
> The earlier `kubernetes-deploy.yaml` workflow (Vault → Kanidm → `kubectl`) was removed on 2026-10-08. It never deployed anything and printed credentials to the job log.
|
||||
|
||||
**Required secrets**:
|
||||
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| `REGISTRY_TOKEN` | Docker registry authentication |
|
||||
| `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
|
||||
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
|
||||
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
|
||||
| `GITOPS_TOKEN` | Gitea token with write and PR access to `home-kubernetes` |
|
||||
|
||||
### Infrastructure Topology
|
||||
|
||||
@@ -164,8 +159,7 @@ Gitea (git push / tag)
|
||||
├── build.yaml → Docker Build → Gitea Container Registry
|
||||
│ (gitea.home.hrajfrisbee.cz)
|
||||
│
|
||||
└── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
|
||||
(192.168.0.31:6443)
|
||||
└── gitops-update.yaml → PR in home-kubernetes → K8s Cluster
|
||||
```
|
||||
|
||||
This is a self-hosted infrastructure stack:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
||||
|
||||
**Current milestone:** M4 — IO layer behind interfaces ✅
|
||||
**Current milestone:** M6 — Go-native HTML frontend
|
||||
**Started:** 2026-05-04
|
||||
**Last updated:** 2026-05-07
|
||||
**Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -97,10 +97,10 @@ Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narr
|
||||
|
||||
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
|
||||
|
||||
- [ ] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/`
|
||||
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
|
||||
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
|
||||
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
||||
- [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
|
||||
- [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f`
|
||||
- [x] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation — `40e4a9e`
|
||||
- [x] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
||||
|
||||
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
|
||||
|
||||
@@ -110,13 +110,14 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity
|
||||
|
||||
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
||||
|
||||
- [ ] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
|
||||
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
||||
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
||||
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
||||
- [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)
|
||||
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
||||
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
|
||||
- [x] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` — `c85748b`
|
||||
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
||||
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
||||
- [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238`
|
||||
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
|
||||
- [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
|
||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets — (pending merge)
|
||||
|
||||
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
|
||||
|
||||
@@ -154,6 +155,7 @@ Goal: Go is the one true backend.
|
||||
|
||||
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
|
||||
|
||||
- 2026-05-07 — `/api/version` excluded from parity diff by design. Each binary's tag/commit/build_date is identity, not a data contract — diffing it would always flag a diff between independently built backends. Route remains reachable via `make parity ARGS="-route /api/version"` for manual inspection.
|
||||
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
|
||||
- 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting.
|
||||
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# M5.1 — Hand-author Go API structs + emit JSON Schemas
|
||||
|
||||
Companion to:
|
||||
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
|
||||
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (progress tracker — M5.1 row)
|
||||
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep — already merged as `b562ce3` / `32a16ff` / `59223c0`)
|
||||
|
||||
## Context
|
||||
|
||||
M4 (IO layer behind interfaces) just landed. M5 is the JSON-parity contract phase — byte-equal JSON between Python and Go for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version`. Within M5, the work splits four ways:
|
||||
|
||||
- **M5.1 — this plan.** Define the wire contract: hand-authored Go structs with explicit `json:` tags matching Python keys, plus committed JSON Schemas generated by `github.com/invopop/jsonschema`. **Schemas only — no handlers, no Python `/api/X` routes, no parity tool.**
|
||||
- M5.2 — Implement Go handlers that compose `services/*` results into these structs.
|
||||
- M5.3 — Add Python `/api/X` shadow endpoints in [app.py](app.py).
|
||||
- M5.4 — `cmd/parity/main.go` + `make parity` target.
|
||||
|
||||
The recent Python view-model extraction (`scripts/views.py`) lays the groundwork: every Python builder now returns a plain dict that an `/api/X` shadow can `jsonify` (with one minor unwrap step — see decision #1 below). M5.1 is the matching Go side: types and schemas that pin down the contract before any code writes JSON to a wire.
|
||||
|
||||
## Key design decisions
|
||||
|
||||
1. **Wire format is nested objects, not strings-of-JSON.** The Python view-model dicts contain three template-only fields that are pre-serialized JSON strings: `member_data`, `month_labels_json`, `raw_payments_json`. Those exist purely to feed inline `<script>` blocks in Jinja templates ([scripts/views.py:227-237](scripts/views.py#L227-L237)). The `/api/X` JSON contract uses the **un-stringified** nested form. Rationale:
|
||||
- The master design doc references `total_balance`, `original_expected`, `attendance_count` as struct fields ([2026-05-03-2349-go-backend-rewrite.md:223-225](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L223-L225)), which are *inside* `member_data` — only meaningful if it's a nested object.
|
||||
- The `Expected` `MarshalJSON` design (emit `42` or `"?"`) ([same doc:233-238](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L233-L238)) only fires inside `member_data`; pointless if that field is a string.
|
||||
- Strings-of-JSON make `invopop/jsonschema` schemas useless for those fields (`{"type": "string"}` describes nothing).
|
||||
- The Python prep plan ([2026-05-07-1431-m5-json-api-parity.md:87-89](docs/plans/2026-05-07-1431-m5-json-api-parity.md#L87-L89)) already anticipates this: "that route will produce a sibling dict with the wrappers stripped".
|
||||
|
||||
This refines (does not contradict) M5.3's "no transformation" wording. M5.3's `/api/X` will be `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
|
||||
|
||||
2. **New package: `internal/web/api/`.** The Go side has no `api/` package today — only [internal/web/server.go](go/internal/web/server.go) with one hello route. The api package owns wire types and (in M5.2) handlers. Sub-files per route keep diffs small.
|
||||
|
||||
3. **Wire types are separate from `domain/reconcile.Result`.** [domain/reconcile/reconcile.go:88-92](go/internal/domain/reconcile/reconcile.go#L88-L92) defines `Result`, `MemberResult`, `MonthData`, `TxEntry`, etc. — none have `json:` tags. **Don't tag the domain types**: that bleeds wire concerns into pure logic and locks the JSON contract to internal field names. Define wire types in `internal/web/api/` and convert in M5.2's handlers.
|
||||
|
||||
4. **`Expected{Value int; Unknown bool}` with custom `MarshalJSON`/`UnmarshalJSON`** for junior `expected` and `original_expected`. Already prescribed by the master design.
|
||||
|
||||
5. **Transaction `amount` / `inferred_amount` may be `int` or `""`** (Sheets `UNFORMATTED_VALUE` returns empty string for blank cells per `scripts/match_payments.py` rows 250-260 in the views.py exploration). Use a custom `SheetsNumber` type with `MarshalJSON`/`UnmarshalJSON` that emits `0`/`null` for empty and the number otherwise. Document in code comment with a one-liner. Verify exact behavior by inspecting one or two existing scrubbed reconcile fixtures before coding.
|
||||
|
||||
6. **Schema generation lives in `internal/web/api/schemagen_test.go`** with `-update` flag, à la golden tests. Default test run (`go test ./internal/web/api/...`) re-generates schemas in memory and asserts byte-equality vs the committed files in `tests/fixtures/api-schema/`. `go test -update ./internal/web/api/...` rewrites the committed files. Avoids a separate `cmd/gen-api-schema` binary; CI catches drift automatically.
|
||||
|
||||
7. **One schema per route**, named to match the Go type:
|
||||
```
|
||||
go/tests/fixtures/api-schema/adults.schema.json
|
||||
go/tests/fixtures/api-schema/juniors.schema.json
|
||||
go/tests/fixtures/api-schema/payments.schema.json
|
||||
go/tests/fixtures/api-schema/version.schema.json
|
||||
```
|
||||
|
||||
8. **Adults and Juniors are *not* the same type.** Their outer shapes match (same keys), but `MonthCell.Text` semantics differ (juniors render `"?"`, `"?(3)"`, `:NJ,MA` breakdowns) and `member_data` semantics differ (juniors carry `Expected` sentinel). Define `AdultsResponse` and `JuniorsResponse` separately even if they share most field types — clearer schemas and easier to evolve independently. Share scalar types (`Transaction`, `Exception`, `MonthCell`, `TotalCell`) in `types.go`.
|
||||
|
||||
9. **Money is integer CZK** ([master design:55-56](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L55-L56)). All amount fields use `int`. The one exception: `member_data[name].months[YYYY-MM].paid` is a `float64` in the Python output (proportional allocation produces fractional CZK like `33.333333`). Use `float64` only there; document the why in a one-line comment.
|
||||
|
||||
## Files to create
|
||||
|
||||
```
|
||||
go/internal/web/api/
|
||||
├── types.go # SheetsNumber, Expected, Exception, Transaction, MonthCell, TotalCell, MemberRow
|
||||
├── adults.go # AdultsResponse + adults-specific MemberData
|
||||
├── juniors.go # JuniorsResponse + juniors-specific MemberData (with Expected fields)
|
||||
├── payments.go # PaymentsResponse
|
||||
├── version.go # VersionResponse
|
||||
└── schemagen_test.go # generates + golden-asserts schemas
|
||||
|
||||
go/tests/fixtures/api-schema/
|
||||
├── adults.schema.json
|
||||
├── juniors.schema.json
|
||||
├── payments.schema.json
|
||||
└── version.schema.json
|
||||
```
|
||||
|
||||
## Struct skeleton (illustrative, not final wording)
|
||||
|
||||
```go
|
||||
// internal/web/api/types.go
|
||||
|
||||
// SheetsNumber wraps a value that may arrive from Google Sheets as either
|
||||
// a JSON number or "" (empty string for blank cells). Marshals as 0 when
|
||||
// missing; the consumer treats Missing+Value=0 as "no data".
|
||||
type SheetsNumber struct {
|
||||
Value float64
|
||||
Missing bool
|
||||
}
|
||||
|
||||
// Expected carries a junior's expected fee or the "?" sentinel
|
||||
// (single-attendance month requires manual review).
|
||||
type Expected struct {
|
||||
Value int
|
||||
Unknown bool
|
||||
}
|
||||
|
||||
type Exception struct {
|
||||
Amount int `json:"amount"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type Transaction struct {
|
||||
Date string `json:"date"`
|
||||
Amount SheetsNumber `json:"amount"`
|
||||
ManualFix string `json:"manual_fix"`
|
||||
Person string `json:"person"`
|
||||
Purpose string `json:"purpose"`
|
||||
InferredAmount SheetsNumber `json:"inferred_amount"`
|
||||
Sender string `json:"sender"`
|
||||
Message string `json:"message"`
|
||||
BankID string `json:"bank_id"`
|
||||
}
|
||||
|
||||
type MonthCell struct {
|
||||
Text string `json:"text"`
|
||||
Overridden bool `json:"overridden"`
|
||||
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
|
||||
Amount int `json:"amount"`
|
||||
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
|
||||
RawMonth string `json:"raw_month"` // YYYY-MM
|
||||
Tooltip string `json:"tooltip"`
|
||||
}
|
||||
|
||||
type TotalCell struct {
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MemberRow struct {
|
||||
Name string `json:"name"`
|
||||
Months []MonthCell `json:"months"`
|
||||
Balance int `json:"balance"`
|
||||
UnpaidPeriods string `json:"unpaid_periods"`
|
||||
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
|
||||
PayableAmount int `json:"payable_amount"`
|
||||
}
|
||||
|
||||
type Credit struct {
|
||||
Name string `json:"name"`
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// internal/web/api/adults.go
|
||||
type AdultsMonthData struct {
|
||||
Status string `json:"status"`
|
||||
Expected int `json:"expected"`
|
||||
Paid float64 `json:"paid"` // float — proportional allocator can produce 33.333…
|
||||
Exception *Exception `json:"exception"`
|
||||
AmountToPay int `json:"amount_to_pay"`
|
||||
// ... transactions, others, etc — match exact keys from result["members"][name]["months"][YYYY-MM]
|
||||
}
|
||||
|
||||
type AdultsMemberData struct {
|
||||
TotalBalance int `json:"total_balance"`
|
||||
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM key
|
||||
Transactions []Transaction `json:"transactions"`
|
||||
}
|
||||
|
||||
type AdultsResponse struct {
|
||||
Months []string `json:"months"`
|
||||
RawMonths []string `json:"raw_months"`
|
||||
Results []MemberRow `json:"results"`
|
||||
Totals []TotalCell `json:"totals"`
|
||||
MemberData map[string]AdultsMemberData `json:"member_data"`
|
||||
MonthLabels map[string]string `json:"month_labels"` // was month_labels_json (string)
|
||||
RawPayments map[string][]Transaction `json:"raw_payments"` // was raw_payments_json (string)
|
||||
Credits []Credit `json:"credits"`
|
||||
Debts []Credit `json:"debts"`
|
||||
Unmatched []Transaction `json:"unmatched"`
|
||||
AttendanceURL string `json:"attendance_url"`
|
||||
PaymentsURL string `json:"payments_url"`
|
||||
BankAccount string `json:"bank_account"`
|
||||
CurrentMonth string `json:"current_month"`
|
||||
}
|
||||
```
|
||||
|
||||
`JuniorsResponse` mirrors `AdultsResponse` but the inner `MonthData` carries `Expected` and `OriginalExpected` (both `Expected` type), and adds the `:NJ,MA` breakdown fields produced by [scripts/views.py:290-298](scripts/views.py#L290-L298).
|
||||
|
||||
`PaymentsResponse`:
|
||||
|
||||
```go
|
||||
type PaymentsResponse struct {
|
||||
GroupedPayments map[string][]Transaction `json:"grouped_payments"`
|
||||
SortedPeople []string `json:"sorted_people"`
|
||||
AttendanceURL string `json:"attendance_url"`
|
||||
PaymentsURL string `json:"payments_url"`
|
||||
}
|
||||
```
|
||||
|
||||
`VersionResponse` mirrors Python's `BUILD_META` ([app.py:67](app.py#L67)):
|
||||
|
||||
```go
|
||||
type VersionResponse struct {
|
||||
Tag string `json:"tag"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
}
|
||||
```
|
||||
|
||||
Exact fields inside the per-month `MonthData`/`MemberData` will be finalized by inspecting **one** scrubbed `member_data` JSON dump from a current `/adults` and `/juniors` call (see Verification step 1) — names and types have to be identical.
|
||||
|
||||
## Reusable existing code
|
||||
|
||||
- `domain/reconcile.Result` ([go/internal/domain/reconcile/reconcile.go:88](go/internal/domain/reconcile/reconcile.go#L88)) — source data for adults/juniors. M5.2 maps it to wire types; M5.1 only needs to know the field set.
|
||||
- `web.BuildInfo` ([go/internal/web/server.go:11-15](go/internal/web/server.go#L11-L15)) — already wired through `cmd/fuj/main.go:79`. The `VersionResponse` type is a thin renaming of this with json tags. Decide in M5.2 whether to *also* tag `BuildInfo` directly (it's not really domain) or keep `VersionResponse` separate. M5.1 just defines the wire struct.
|
||||
- `scripts/views.py` builders — the spec for every key/type. Treat as authoritative.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. **Add `github.com/invopop/jsonschema` dependency.** `cd go && go get github.com/invopop/jsonschema && go mod tidy`. Single commit.
|
||||
2. **Capture one fresh `member_data` dump for adults and juniors** to pin down inner-dict field names and types precisely (especially `paid` precision, `transactions[]` shape, `others[]` if present). Run `make web-py` on a known-good cache, hit `/adults` and `/juniors`, dump `view_model["member_data"]` to a scratch file (gitignored), and inspect. **Do not commit raw dumps** — PII rule. This is for shape inspection only.
|
||||
3. **Author `internal/web/api/types.go`** with `SheetsNumber`, `Expected`, `Exception`, `Transaction`, `MonthCell`, `TotalCell`, `MemberRow`, `Credit`. Implement `MarshalJSON`/`UnmarshalJSON` for `SheetsNumber` and `Expected`. Use struct tags `json:"snake_case_key"` matching Python exactly.
|
||||
4. **Author `internal/web/api/adults.go`** with `AdultsMonthData`, `AdultsMemberData`, `AdultsResponse`. Cross-check every key against the dump from step 2.
|
||||
5. **Author `internal/web/api/juniors.go`** similarly, with `Expected` fields and the J/A breakdown.
|
||||
6. **Author `internal/web/api/payments.go` and `version.go`** (small).
|
||||
7. **Author `internal/web/api/schemagen_test.go`** that:
|
||||
- Imports `github.com/invopop/jsonschema`.
|
||||
- Defines a `schemaCases` slice: `{name: "adults", typ: AdultsResponse{}, ...}` etc.
|
||||
- For each case, generates a schema, marshals indented JSON, compares against committed file at `../../tests/fixtures/api-schema/<name>.schema.json`.
|
||||
- Honors a `-update` flag (`flag.Bool`) that rewrites the committed file instead of asserting.
|
||||
- One `t.Run(case.name, ...)` per route for clear failure output.
|
||||
8. **Run with `-update` once to populate** `tests/fixtures/api-schema/*.schema.json`. Eyeball each schema: required fields list, oneOf for `Expected`, additionalProperties on map types. Commit.
|
||||
9. **Lint + test:** `cd go && go vet ./... && make go-test && make go-lint`. Fix any issues. (Expect zero — this is read-only data structures.)
|
||||
10. **CHANGELOG entry** + tick `M5.1` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:100](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L100) with the merge SHA.
|
||||
11. **Branch + MR per CLAUDE.md workflow:** branch `feat/go-m5-1-api-structs`, push with `-u`, open MR via `tea pr create`. Do not merge from CLI.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd go && go test ./internal/web/api/...` — **schemas regenerate identically** to committed files.
|
||||
2. `cd go && go test ./internal/web/api/... -update` then `git diff go/tests/fixtures/api-schema/` — diff is empty (idempotent generation).
|
||||
3. `cd go && make go-lint` — clean.
|
||||
4. `cd go && go vet ./...` — clean.
|
||||
5. Manual schema inspection: open `adults.schema.json`, confirm:
|
||||
- Top-level `required` list contains every Python key.
|
||||
- `member_data` is `additionalProperties: { ... AdultsMemberData ... }` (a map keyed by name).
|
||||
- `expected` and `original_expected` (juniors only) are `oneOf: [{type: integer}, {const: "?"}]`.
|
||||
- `amount` and `inferred_amount` on `Transaction` accept number or empty/null.
|
||||
6. **No production code paths exercised yet** — handlers come in M5.2. Compile-time success + schema golden-test = M5.1 done.
|
||||
|
||||
## Out of scope (later M5 tasks)
|
||||
|
||||
- Wiring Go HTTP handlers for `/api/X` (M5.2).
|
||||
- Adding Python `/api/X` shadow endpoints (M5.3) — including the `unwrap_json_strings(view_model)` shim noted in design decision #1.
|
||||
- `cmd/parity/main.go` and `make parity` target (M5.4).
|
||||
- Tagging `domain/reconcile.Result` with `json:` tags — explicitly avoided.
|
||||
- Refactoring the strings-of-JSON fields out of the Python view-model — they stay in `views.py` for the template path, the `/api/X` shadow unwraps them.
|
||||
@@ -0,0 +1,113 @@
|
||||
# M5.3 — Python `/api/X` shadow endpoints
|
||||
|
||||
Companion to:
|
||||
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
|
||||
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (M5.3 row)
|
||||
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep)
|
||||
- [2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md](2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md) (Go wire types)
|
||||
|
||||
## Context
|
||||
|
||||
M5.1 (Go wire types + JSON Schemas) and M5.2 (Go HTTP handlers for `/api/adults` `/api/juniors` `/api/payments` `/api/version`) have merged. M5.3 mirrors the same four endpoints on the Python Flask side so M5.4's `cmd/parity` tool can hit both backends and diff the JSON. After M5.3, every byte the Go side emits has a Python counterpart to compare against.
|
||||
|
||||
The Python view-model builders ([scripts/views.py](scripts/views.py)) already produce dicts very close to the wire shape — except three template-only fields (`member_data`, `month_labels_json`, `raw_payments_json`) are pre-`json.dumps`'d for inline `<script>` blocks. M5.1's plan called this out explicitly: M5.3's `/api/X` is `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
|
||||
|
||||
## Approach
|
||||
|
||||
Add four shadow routes to [app.py](app.py) and one private unwrap helper. Builders and templates are untouched.
|
||||
|
||||
### Decisions
|
||||
|
||||
1. **Unwrap shim lives in `app.py`**, not `scripts/views.py`. It's 4 lines, only the API routes use it, and it's parity-only scaffolding that M8 will delete. Keeping `views.py` free of HTTP-layer concerns means cleaner deletion later.
|
||||
2. **No data-loading helper extraction.** Each shadow route duplicates ~8 lines of cache loads from its sibling HTML route. A helper would have to thread `attendance_url` / `payments_url` / `bank_account` and the adults-vs-juniors-vs-payments branching back out — net negative for code that M8 will erase wholesale.
|
||||
3. **Drop `record_step` calls in API routes.** `record_step` only feeds `inject_render_time` (a Jinja `context_processor`); JSON responses don't go through templates, so timing breakdown has no consumer.
|
||||
4. **`/api/version` is a one-liner.** `BUILD_META` already has the keys (`tag`, `commit`, `build_date`) Go emits. Just `jsonify(BUILD_META)`. Existing `/version` route stays as-is — the new endpoint sits alongside.
|
||||
5. **Tests assert key sets and unwrap, not values.** Hard-code `EXPECTED_ADULTS_KEYS` etc. as module constants in [tests/test_app.py](tests/test_app.py). Catches drift in unit tests rather than waiting for M5.4 parity diffs. Type-check the unwrapped fields (`isinstance(member_data, dict)` etc.) to prove the shim ran.
|
||||
|
||||
### The shim
|
||||
|
||||
```python
|
||||
def _unwrap_view_model_for_api(vm: dict) -> dict:
|
||||
out = dict(vm)
|
||||
out["member_data"] = _json.loads(out.pop("member_data"))
|
||||
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
|
||||
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
|
||||
return out
|
||||
```
|
||||
|
||||
Note the **rename**: `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` (matches Go contract per [adults.go](go/internal/web/api/adults.go) JSON tags).
|
||||
|
||||
### Route skeleton (`/api/adults`)
|
||||
|
||||
```python
|
||||
@app.route("/api/adults")
|
||||
def api_adults():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
if not members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
members, sorted_months = members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
vm = build_adults_view_model(
|
||||
members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
```
|
||||
|
||||
`/api/juniors` mirrors with `adapt_junior_members` + `JUNIOR_SHEET_GID` + `build_juniors_view_model`. `/api/payments` skips the unwrap (its builder has no JSON-string fields): `return jsonify(vm)`. `/api/version`: `return jsonify(BUILD_META)`.
|
||||
|
||||
## Files to modify
|
||||
|
||||
- [app.py](app.py)
|
||||
- L10: add `jsonify` to `from flask import ...`.
|
||||
- Add `_unwrap_view_model_for_api` near [BUILD_META](app.py#L67) (it already imports `json as _json`).
|
||||
- Add four routes: `/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`. Place them after [`/version`](app.py#L143) for grouping.
|
||||
- [tests/test_app.py](tests/test_app.py)
|
||||
- Add `EXPECTED_ADULTS_KEYS`, `EXPECTED_JUNIORS_KEYS`, `EXPECTED_PAYMENTS_KEYS`, `EXPECTED_VERSION_KEYS` module constants (sourced from [adults.go](go/internal/web/api/adults.go) / [juniors.go](go/internal/web/api/juniors.go) / [payments.go](go/internal/web/api/payments.go) / [version.go](go/internal/web/api/version.go) JSON tags).
|
||||
- Four new test functions: `test_api_adults`, `test_api_juniors`, `test_api_payments`, `test_api_version`. Reuse the existing `_bypass_cache` patcher and the `fetch_sheet_data` / `fetch_exceptions` / `get_members_with_fees` / `get_junior_members_with_fees` mocks already in the file.
|
||||
- Each adults/juniors test asserts: `200`, `response.is_json`, `set(json.keys()) == EXPECTED_*_KEYS`, `isinstance(json["member_data"], dict)`, `isinstance(json["month_labels"], dict)`, `isinstance(json["raw_payments"], dict)`.
|
||||
- [CHANGELOG.md](CHANGELOG.md): post-merge entry per CLAUDE.md format.
|
||||
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:102](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L102): tick M5.3 with merge SHA.
|
||||
|
||||
## Reusable existing code
|
||||
|
||||
- [`build_adults_view_model`](scripts/views.py#L64), [`build_juniors_view_model`](scripts/views.py#L240), [`build_payments_view_model`](scripts/views.py#L432) — call as-is, no changes.
|
||||
- [`reconcile`](scripts/match_payments.py) and [`adapt_junior_members`](scripts/views.py#L48) — same.
|
||||
- [`get_cached_data`](app.py#L36), [`fetch_sheet_data`](scripts/match_payments.py), [`fetch_exceptions`](scripts/match_payments.py), [`get_members_with_fees`](scripts/attendance.py), [`get_junior_members_with_fees`](scripts/attendance.py) — same call sites as the HTML routes.
|
||||
- [`BUILD_META`](app.py#L67) — already shaped to match Go's `VersionResponse`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make test` — all existing tests still pass; four new `test_api_*` tests pass.
|
||||
2. `python -c "import app"` — catches the missing `jsonify` import.
|
||||
3. `make web-py`, then:
|
||||
- `curl -s localhost:5001/api/version | jq .` → `{tag, commit, build_date}`.
|
||||
- `curl -s localhost:5001/api/adults | jq 'keys'` → 14 keys, no `_json` suffix anywhere.
|
||||
- `curl -s localhost:5001/api/adults | jq '.member_data | type, .month_labels | type, .raw_payments | type'` → all `"object"` (proves unwrap).
|
||||
- Same checks on `/api/juniors` and `/api/payments`.
|
||||
4. Visit `/adults`, `/juniors`, `/payments` in browser — HTML still renders identically (regression check on builders).
|
||||
5. **Optional pre-M5.4 peek:** `make web-go` on :8080 + `make web-py` on :5001, then `diff <(curl -s :5001/api/adults | jq -S .) <(curl -s :8080/api/adults | jq -S .)`. Expect non-zero diff (raw transaction key shape, see below) — that is fine; M5.4 surfaces and resolves these.
|
||||
|
||||
## Out of scope (M5.4 will surface and resolve)
|
||||
|
||||
These are known parity friction points; **don't fix in M5.3** — the whole point of M5.4 is to enumerate them:
|
||||
|
||||
- **`raw_payments[name][i]` row shape**: Python emits raw Google Sheets row dicts with column-header keys (e.g. `"VS"`, `"Sync ID"`); Go's `RawTransaction` uses snake_case (`vs`, `sync_id`). Keys and types will diverge.
|
||||
- **`unmatched[]`**: same divergence (same raw row dicts).
|
||||
- **`null` vs missing keys**: Python omits keys never set; Go zero-value structs may emit `null`/`""` depending on `omitempty`.
|
||||
- **`Decimal` / float precision** in `grouped_payments` amounts.
|
||||
- **Field insertion order**: `jsonify` preserves insertion order (Python 3.7+); Go marshals struct fields in declaration order. Likely fine, parity tool will tell.
|
||||
|
||||
## Branch + MR
|
||||
|
||||
Per CLAUDE.md: branch `feat/go-m5-3-python-api-shadow`, push with `-u`, open MR via `tea pr create --title ... --description ... --base main --head feat/go-m5-3-python-api-shadow`. Do not merge from CLI.
|
||||
@@ -0,0 +1,152 @@
|
||||
# M5.4 — Parity diff binary (`cmd/parity`) + `make parity`
|
||||
|
||||
## Context
|
||||
|
||||
Per [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:103](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L103), M5.4 is the next milestone in the Go rewrite. M5.1–M5.3 already landed:
|
||||
|
||||
- M5.1: hand-authored Go response structs at [go/internal/web/api/](go/internal/web/api/) + JSON Schemas in [go/tests/fixtures/api-schema/](go/tests/fixtures/api-schema/).
|
||||
- M5.2: Go `/api/version|adults|juniors|payments` handlers in [go/internal/web/api/handler.go](go/internal/web/api/handler.go).
|
||||
- M5.3: Python shadow endpoints in [app.py:157-224](app.py#L157-L224) using `_unwrap_view_model_for_api` for the same JSON shape.
|
||||
|
||||
What's missing: a tool that proves the two backends actually agree on the wire. The M5 gate says "byte-equal JSON between Python and Go for every route." Without a diffing tool, drift between the two implementations slips in silently and we can't gate further milestones (M6 frontend, M7 watch period) on parity.
|
||||
|
||||
This task delivers the **parity contract enforcer**: a Go binary that fetches `/api/*` from both backends, scrubs an allowlist of expected diffs, and prints `cmp.Diff` for everything else. Both backends read the same live Google Sheets — that shared state is what makes parity meaningful, no scenario fixtures needed.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
- New binary `go/cmd/parity/main.go` plus a small support package for the allowlist scrubber.
|
||||
- A unit test for the scrubber.
|
||||
- New `make parity` target.
|
||||
- `go-cmp` promoted to a direct dependency.
|
||||
|
||||
Out of scope (explicitly):
|
||||
- CI integration / nightly job — that's M7.2.
|
||||
- Fixture-driven offline parity — pure-fn parity already runs via `make go-parity` (M3).
|
||||
- Hooking `make parity` into `make go-test-all` — leave it manual since it requires two live servers.
|
||||
|
||||
## Approach
|
||||
|
||||
### 1. New binary: `go/cmd/parity/main.go`
|
||||
|
||||
Standalone binary (mirrors task spec — not a subcommand of `fuj`). Stdlib `flag`, no third-party CLI lib.
|
||||
|
||||
**Flags:**
|
||||
- `-py` — Python base URL, default `http://localhost:5001`
|
||||
- `-go` — Go base URL, default `http://localhost:8080`
|
||||
- `-route` — optional single route to diff (e.g. `/api/adults`); empty means iterate all four
|
||||
- `-timeout` — per-request timeout, default `30s` (sheet fetches are slow on cold cache)
|
||||
|
||||
**Routes**, hard-coded:
|
||||
```go
|
||||
var routes = []string{"/api/version", "/api/adults", "/api/juniors", "/api/payments"}
|
||||
```
|
||||
All `GET`, no query params (verified in [app.py:157-224](app.py#L157-L224) and [go/internal/web/api/handler.go:27-68](go/internal/web/api/handler.go#L27-L68)).
|
||||
|
||||
**Per route, the binary:**
|
||||
1. `GET py+route` and `GET go+route` (sequential — keep it simple; total wall time ~1–2s on warm cache).
|
||||
2. Verify both return HTTP 200; on non-200, print body and mark route as ERROR.
|
||||
3. Decode each body into `map[string]any` (NOT into `api.AdultsResponse` — using the typed struct would silently drop unknown Python-side keys, defeating the diff. Map gives `cmp.Diff` clean dotted-path field names for free.).
|
||||
4. Run `scrub(m, allowlist)` on both decoded maps.
|
||||
5. `diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())`.
|
||||
6. If `diff != ""`, print `=== /api/X ===` header followed by the diff; track for exit code.
|
||||
|
||||
**Exit codes:**
|
||||
- `0` — all routes match (or the only diffs were under the allowlist).
|
||||
- `1` — at least one route had a non-allowlisted diff.
|
||||
- `2` — at least one route failed to fetch / parse (HTTP error, timeout, non-JSON body).
|
||||
|
||||
This makes the binary CI-friendly when M7.2 lands.
|
||||
|
||||
**Output**: human-readable to stdout — header per route, then "OK" or the diff. Final summary line: `parity: 4/4 routes match` or `parity: 2/4 match, 1 diff, 1 error`.
|
||||
|
||||
### 2. Allowlist scrubber
|
||||
|
||||
Live in same package (`main`). Keep it tiny — no need for a separate sub-package.
|
||||
|
||||
```go
|
||||
var defaultAllowlist = []string{"render_time.total", "build_meta"}
|
||||
|
||||
// scrub walks m and deletes any key whose dotted path matches an allowlist entry.
|
||||
// "render_time.total" deletes m["render_time"]["total"] only (preserves render_time.breakdown).
|
||||
// "build_meta" (no dot) deletes m["build_meta"] entirely.
|
||||
func scrub(m map[string]any, paths []string) { ... }
|
||||
```
|
||||
|
||||
Today these fields **don't appear in the JSON** — they're Jinja template-only ([app.py:91-110](app.py#L91-L110)) and the Go side only logs render time via [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go). So today the scrub is a no-op. The implementation is forward-compatible insurance: if someone later adds either field to a JSON response on one side only, the parity binary already tolerates it.
|
||||
|
||||
The implementation note in [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go) ("the elapsed value maps to render_time.total in the M5 JSON allowlist") confirms this is the intended design.
|
||||
|
||||
### 3. Unit test
|
||||
|
||||
Add `go/cmd/parity/scrub_test.go` with a couple of cases:
|
||||
- Scrubbing top-level key (`build_meta`) — key removed; siblings untouched.
|
||||
- Scrubbing nested key (`render_time.total`) — only `total` removed; `breakdown` preserved.
|
||||
- Path that doesn't exist — no-op, no error.
|
||||
- Map without the parent key — no-op (don't panic).
|
||||
|
||||
Runs as part of normal `make go-test` (no `-tags` needed).
|
||||
|
||||
### 4. Makefile target
|
||||
|
||||
Add to [Makefile](Makefile):
|
||||
|
||||
```make
|
||||
parity:
|
||||
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
|
||||
```
|
||||
|
||||
Add `parity` to the `.PHONY` line at [Makefile:1](Makefile#L1) and a `help` entry like:
|
||||
|
||||
```
|
||||
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
|
||||
```
|
||||
|
||||
Don't depend on `go-build` — `go run` compiles ad-hoc, and parity is interactive enough that the slight rebuild cost doesn't matter.
|
||||
|
||||
### 5. Dependency
|
||||
|
||||
`github.com/google/go-cmp v0.7.0` is already a transitive dep ([go/go.sum:24-25](go/go.sum#L24-L25)) but not in `go.mod`'s `require` block. After adding the import, run `go mod tidy` inside `go/` to promote it to direct. No new external deps.
|
||||
|
||||
## Files to add / modify
|
||||
|
||||
**Add:**
|
||||
- [go/cmd/parity/main.go](go/cmd/parity/main.go) — flags, route loop, fetch+scrub+diff, exit codes
|
||||
- [go/cmd/parity/scrub_test.go](go/cmd/parity/scrub_test.go) — unit tests for the scrubber
|
||||
|
||||
**Modify:**
|
||||
- [Makefile](Makefile) — `.PHONY`, help block, `parity` target
|
||||
- [go/go.mod](go/go.mod) — promote `go-cmp` to direct (via `go mod tidy`)
|
||||
- [go/go.sum](go/go.sum) — likely unchanged (already pinned)
|
||||
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — tick M5.4, add commit SHA
|
||||
- [CHANGELOG.md](CHANGELOG.md) — entry per project convention
|
||||
|
||||
**Per [CLAUDE.md](CLAUDE.md) plans convention:** copy this plan to `docs/plans/2026-05-07-HHMM-m5-4-parity-binary.md` (timestamp via `date "+%Y-%m-%d-%H%M"`) before opening the MR, since plan files are committed for posterity.
|
||||
|
||||
## Existing utilities to reuse
|
||||
|
||||
- `cmp.Diff` + `cmpopts.EquateEmpty()` from `github.com/google/go-cmp/cmp` (already in go.sum).
|
||||
- Stdlib `net/http`, `encoding/json`, `flag` — sufficient; no need to pull a CLI framework.
|
||||
- No existing scrubber to reuse — the one in [scripts/scrub_fixtures.py](scripts/scrub_fixtures.py) operates on the *capture* path and renames PII; the parity scrubber is a different concern (path-based deletion of expected diffs).
|
||||
|
||||
## Verification
|
||||
|
||||
Manual smoke test (the binary is meant to be run against live servers):
|
||||
|
||||
1. **Sanity / build:** `cd go && go build ./cmd/parity && go test ./cmd/parity/...` — compiles + scrubber unit test passes.
|
||||
2. **Both backends up:**
|
||||
- Terminal A: `make web-py`
|
||||
- Terminal B: `make web-go`
|
||||
- Terminal C: `make parity`
|
||||
Expected: `parity: 4/4 routes match`, exit 0.
|
||||
3. **Negative test:** add a literal extra field to one Go handler temporarily (e.g. `"diagnostic": "test"` in `VersionResponse`), rebuild, re-run `make parity`. Expected: non-zero exit, diff shown for `/api/version`. Revert.
|
||||
4. **Allowlist test:** in a unit test (or by manually constructing a payload with `render_time.total` injected), confirm the scrubber removes it before the diff stage.
|
||||
5. **CHANGELOG** entry added; progress tracker ticked with the merge commit SHA.
|
||||
|
||||
Per [CLAUDE.md](CLAUDE.md) branching policy: this is a feature, so work happens on `feat/go-m5-4-parity-binary`, push with `-u`, open MR with `tea pr create --base main --head feat/go-m5-4-parity-binary`. User merges in Gitea.
|
||||
|
||||
## Notes & risks
|
||||
|
||||
- **Cache-warmth dependency:** Both backends cache aggressively. A cold-cache fetch on Python can take 30s+ — hence the configurable timeout. If parity is run immediately after `flush-cache`, expect the first run to be slow.
|
||||
- **Order-sensitive lists:** `cmp.Diff` on `map[string]any` treats slices as ordered. If either backend ever returns members/transactions in a different order, the diff will flag it — that's a real parity bug, not a false positive, so this is correct behavior. If we hit ordering instability later, fix the source, don't add `cmpopts.SortSlices`.
|
||||
- **Float formatting:** Both sides go through `encoding/json` (Go) and `jsonify` (Python `json.dumps`). Floats in totals/balances may format differently (`123` vs `123.0`). If this surfaces, the Go side already uses typed structs with `int` where appropriate — investigate at the source rather than allowlisting.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Plan: Go rewrite — M6.1 Template skeleton
|
||||
|
||||
## Context
|
||||
|
||||
M5 finished — Go and Python now produce byte-equal JSON across `/api/{adults,juniors,payments,version}` and `cmd/parity` enforces it. See [progress tracker §M5](2026-05-03-2349-go-backend-rewrite-progress.md#L96-L106).
|
||||
|
||||
M6 begins the Go-native HTML frontend that lets us actually retire the Python web UI. **M6.1 is the foundation step**: stand up the base HTML layout, shared nav, terminal-green-on-black theme, and the `embed.FS` plumbing that every subsequent M6 page (adults, juniors, payments, sync, flush, qr) will compose into. After M6.1, opening :8080 shows chrome (header, nav, footer) on every route with placeholder bodies — no real data wiring; that lands in M6.2+.
|
||||
|
||||
This is **not a Jinja port**. The Python templates are reference for nav structure and CSS values; the Go layout is designed natively (per the [master plan](2026-05-03-2349-go-backend-rewrite.md#L24-L27): "frontends are allowed to diverge").
|
||||
|
||||
## Current Go web state (what we're building on)
|
||||
|
||||
- [go/internal/web/server.go](../../go/internal/web/server.go) — `Run()` registers `GET /{$}` (text-plain hello) + four `/api/*` routes. `BuildInfo{Version,Commit,BuildDate}` already flows in.
|
||||
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — central handler struct holding `Sources`, `Config`, `Logger`, build fields.
|
||||
- No `templates/`, no `static/`, no `embed` directives, no `html/template` import anywhere in `go/`. We're starting from a blank slate.
|
||||
|
||||
## Approach
|
||||
|
||||
1. **Create `templates/` and `static/` under `go/internal/web/`**, embedded via `//go:embed`.
|
||||
2. **Single base template** (`base.tmpl`) using `html/template` `block`s for `title` and `content`. Each page template defines those blocks; the renderer executes via `ExecuteTemplate(w, "base", data)`.
|
||||
3. **Nav partial** parameterized by a `pageData.Active` string so each page highlights its own link with `class="active"`. Three tiers preserved (Primary / Archived / Tools) — matches Python UX in [templates/adults.html:491-505](../../templates/adults.html#L491-L505).
|
||||
4. **CSS** lifted verbatim from [templates/adults.html:8-487](../../templates/adults.html#L8-L487) into `static/css/app.css`. Page templates `<link>` it from the base layout. M6.2+ reuses the same class names (`.cell-ok`, `.modal-content`, …) without re-extracting.
|
||||
5. **HTML handlers** in a new `go/internal/web/html/` package (sibling of `api/`). One `Handler` struct holding `*Renderer` + `BuildInfo` + `Config`. Routes for `/`, `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache` registered in `server.go`. Each route renders a placeholder body ("Coming in M6.X") inside the base shell — the goal is to prove the chrome works, not to populate data.
|
||||
6. **Replace the existing `helloHandler`**: `GET /{$}` → `http.Redirect` to `/adults` (mirrors Python's meta-refresh in [app.py:112-115](../../app.py#L112-L115)).
|
||||
7. **Static file route**: `GET /static/` served from `staticFS` via `http.StripPrefix("/static/", http.FileServerFS(staticFS))`.
|
||||
8. **Footer keeps it simple**: `{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}`. The Python per-request render-time breakdown is deferred — `middleware/timer.go` only logs to slog today; threading a timer into request context can come back in a later milestone if we miss it.
|
||||
|
||||
## Files to create / modify
|
||||
|
||||
```
|
||||
go/internal/web/
|
||||
├── assets.go NEW — //go:embed templates/* static/*
|
||||
├── render.go NEW — Renderer wraps parsed *template.Template
|
||||
├── server.go MODIFY — drop helloHandler; mount HTML routes + /static/
|
||||
├── html/
|
||||
│ ├── handler.go NEW — html.Handler + page methods
|
||||
│ └── handler_test.go NEW — httptest smoke per route
|
||||
├── templates/
|
||||
│ ├── base.tmpl NEW — <html>/<head>/<body>; nav + content + footer blocks
|
||||
│ ├── partials/
|
||||
│ │ ├── nav.tmpl NEW — three-tier nav, active highlighting
|
||||
│ │ └── footer.tmpl NEW — build meta line
|
||||
│ ├── adults.tmpl NEW — placeholder "Coming in M6.2"
|
||||
│ ├── juniors.tmpl NEW — placeholder "Coming in M6.3"
|
||||
│ ├── payments.tmpl NEW — placeholder "Coming in M6.4"
|
||||
│ ├── sync.tmpl NEW — placeholder "Coming in M6.6"
|
||||
│ └── flush_cache.tmpl NEW — placeholder "Coming in M6.6"
|
||||
└── static/
|
||||
└── css/
|
||||
└── app.css NEW — verbatim from templates/adults.html:8-487
|
||||
```
|
||||
|
||||
## Key design notes
|
||||
|
||||
- **Parse once at startup**: `NewRenderer(fs embed.FS)` calls `template.ParseFS(fs, "templates/*.tmpl", "templates/partials/*.tmpl")`. Parse failure aborts boot — template syntax errors surface immediately, not at first request.
|
||||
- **Per-page cloned templates** to avoid `define "content"` collisions across pages: `Renderer` stores `map[string]*template.Template` keyed by page name; each is `base+partials` clone with that page's `content`/`title` overlaid.
|
||||
- **View model** — small `pageData` struct: `Active` (nav key), `Build` (BuildInfo), `Body` (per-page payload, `any` until M6.2 widens it).
|
||||
- **Active-link logic** in nav partial: `{{ if eq .Active "adults" }}class="active"{{ end }}` — markup-side, no template funcs needed.
|
||||
- **No JS in M6.1.** Member-detail and QR modals + `static/js/*.js` come in M6.5. M6.1 only sets up the embedded-asset machinery.
|
||||
- **Nav anchor labels are literal text** (`[Adults]`, `[Juniors]`, etc.) — keep the brackets in the template strings, they're part of the look.
|
||||
- **`/qr` is not in the nav** — it's an image endpoint, M6.6 territory.
|
||||
|
||||
## Critical files to read
|
||||
|
||||
- [go/internal/web/server.go](../../go/internal/web/server.go) — mux registration; `Run()` signature stays unchanged.
|
||||
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — pattern to mirror for `html.Handler` (same field layout, most fields unused in M6.1).
|
||||
- [templates/adults.html:8-487](../../templates/adults.html#L8-L487) — CSS source, copy verbatim.
|
||||
- [templates/adults.html:491-505](../../templates/adults.html#L491-L505) — nav markup source (three tiers).
|
||||
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md:88-101](2026-05-03-2349-go-backend-rewrite.md#L88-L101) — original layout intent for `web/handlers/`, `web/templates/`, `web/static/`.
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end smoke from a fresh `make go-build`:
|
||||
|
||||
1. `make web-go &` — server boots; no template-parse errors in slog output.
|
||||
2. `curl -i localhost:8080/` → `302` to `/adults`.
|
||||
3. `curl -s localhost:8080/adults | grep -F 'class="active"'` matches the Adults anchor only.
|
||||
4. `curl -sI localhost:8080/static/css/app.css` → `200`, `Content-Type: text/css`.
|
||||
5. Browser at `http://localhost:8080/adults`: terminal-green-on-black theme, three-tier nav at top (Adults active), "Coming in M6.2" placeholder, footer `{tag}@{commit} | built {date}`.
|
||||
6. Click each nav link in turn (`/juniors`, `/payments`, `/sync-bank`, `/flush-cache`) — same chrome, the clicked link is highlighted, per-page placeholder body shows.
|
||||
7. `make go-test` — `web/html/handler_test.go` passes: each route returns 200, `text/html`, `class="active"` only on the matching anchor.
|
||||
8. `make go-lint` clean.
|
||||
9. `make parity` still green (regression check — M6.1 doesn't touch `/api/*`).
|
||||
10. CHANGELOG entry per CLAUDE.md; M6.1 ticked in [progress tracker §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113) with commit SHA.
|
||||
|
||||
## Branch & MR
|
||||
|
||||
`feat/go-m6-1-template-skeleton` per CLAUDE.md branch-per-feature workflow. Open MR via `tea pr create --base main --head feat/go-m6-1-template-skeleton`. User merges in the Gitea browser UI.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Plan: Go rewrite — M6.2 Adults page
|
||||
|
||||
## Context
|
||||
|
||||
M6.1 landed the template skeleton, embed.FS, and HTML routes ([progress §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113), commit `78e5059`). Every page renders chrome + a "Coming in M6.X" placeholder body. **M6.2 fills in `/adults`** — the most data-rich page and the template that drives the rest of the M6 work (juniors mostly mirrors it).
|
||||
|
||||
Acceptance criteria, verbatim from [progress §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114):
|
||||
|
||||
> `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
||||
|
||||
The Go side has done excellent prep: `buildAdultsResponse` in [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) already produces the full view model (currently consumed only by `/api/adults`), the wire types in [go/internal/web/api/types.go](../../go/internal/web/api/types.go) match the Python view-model 1:1, and the CSS is already lifted into [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css). M6.2 is therefore mostly **template authoring + handler wiring + a small filters script**, not a fresh port.
|
||||
|
||||
## Current Go web state (what we're building on)
|
||||
|
||||
- [go/internal/web/server.go:49](../../go/internal/web/server.go#L49) — `mux.HandleFunc("GET /adults", hh.ServeAdults)` already wired.
|
||||
- [go/internal/web/html_handler.go:16-18](../../go/internal/web/html_handler.go#L16-L18) — placeholder handler renders a `PageData{Active, Build}` shell only. Needs to gain access to the data layer.
|
||||
- [go/internal/web/render.go:11](../../go/internal/web/render.go#L11) — `PageData` struct is currently `{Active, Build}` only. Needs to extend or be wrapped by a typed adults view model.
|
||||
- [go/internal/web/api/handler.go:36-45](../../go/internal/web/api/handler.go#L36-L45) — `ServeAdults` already does `loadAll` → `Reconcile` → `buildAdultsResponse`. We will extract that body into an exported method so the HTML handler can reuse it without duplication.
|
||||
- [go/internal/web/api/build_adults.go:17](../../go/internal/web/api/build_adults.go#L17) — `buildAdultsResponse(...) AdultsResponse` returns everything the template needs (`Months`, `Results`, `Totals`, `Credits`, `Debts`, `Unmatched`, `BankAccount`, `CurrentMonth`, …). We pass this struct straight into the template.
|
||||
- [go/internal/web/templates/adults.tmpl](../../go/internal/web/templates/adults.tmpl) — currently a 5-line placeholder. Replace contents.
|
||||
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — all selectors needed (`.cell-ok`, `.cell-unpaid`, `.cell-unpaid-current`, `.cell-overridden`, `.unmatched-row`, `.filter-container`, `.pay-btn`, `.member-row`, …) already present from M6.1.
|
||||
|
||||
Reference for parity: [templates/adults.html](../../templates/adults.html) (Python source). Sections to mirror in markup terms:
|
||||
- Reconcile table — [adults.html:534-585](../../templates/adults.html#L534-L585)
|
||||
- Totals row — [adults.html:571-582](../../templates/adults.html#L571-L582)
|
||||
- Credits / Debts — [adults.html:587-609](../../templates/adults.html#L587-L609)
|
||||
- Unmatched — [adults.html:611-629](../../templates/adults.html#L611-L629)
|
||||
- Filter controls — [adults.html:515-532](../../templates/adults.html#L515-L532)
|
||||
- Filter JS (name + month range) — [adults.html:1019-1051](../../templates/adults.html#L1019-L1051)
|
||||
|
||||
The member-detail modal, Pay-preview modal, and JSON-hydrated `memberData`/`rawPaymentsByPerson` globals are explicitly **out of scope for M6.2** — they belong to M6.5 (modal JS).
|
||||
|
||||
## Approach
|
||||
|
||||
1. **Share data assembly between HTML and JSON** — extract `ServeAdults`'s body into `(*api.Handler).AssembleAdults(ctx) (AdultsResponse, error)` and have `ServeAdults` call it. The HTML handler then calls the same method, keeping `/adults` and `/api/adults` byte-identical in semantics (same loaded data, same reconcile, same view model).
|
||||
2. **Wire HTMLHandler to data** — extend `HTMLHandler` with an `apiHandler *api.Handler` field and pass it from `Run()`. `ServeAdults` becomes: `AssembleAdults` → on error render an error body (or 500) → render `adults.tmpl` with a typed view model wrapping `PageData` + `api.AdultsResponse`.
|
||||
3. **Per-page typed view model** — add `AdultsPageData{ PageData; Data api.AdultsResponse }` in [render.go](../../go/internal/web/render.go) (or a new `view.go`). Template references `.Active`, `.Build` (chrome) and `.Data.Results`, `.Data.Totals`, `.Data.Months`, etc. Keeps the chrome contract for nav/footer untouched.
|
||||
4. **Author `adults.tmpl`** — port markup from [templates/adults.html:489-629](../../templates/adults.html#L489-L629). Notable mechanics:
|
||||
- Filter controls block with `<input id="nameFilter">`, `<select id="fromMonth">`, `<select id="toMonth">`, `Apply` / `All` buttons. Month dropdown options rendered server-side from `.Data.Months` (no client-side hydration needed for filters).
|
||||
- Reconcile table iterates `.Data.Results`; each row's months iterate `row.Months`. Cell `<td>` gets class `cell-{{.Status}}`, plus `cell-unpaid-current` when `.RawMonth >= .Data.CurrentMonth`, plus `cell-overridden` when `.Overridden`. Cells carry `data-month-idx="{{$i}}"` so the filter script can hide columns.
|
||||
- Per-cell Pay button visible on hover when `(unpaid|partial)` and `RawMonth < CurrentMonth` — `<a class="pay-btn" href="/qr?...">Pay</a>` with the QR query string built server-side via a template helper (see Key design notes).
|
||||
- Per-row "Pay All" button when `.PayableAmount > 0`, same href construction using `.UnpaidPeriods` for display and `.RawUnpaidPeriods` for the QR message.
|
||||
- Totals `<tr>` iterates `.Data.Totals`, classes `total-cell-{{.Status}}`.
|
||||
- Credits / Debts / Unmatched sections rendered conditionally on non-empty slices.
|
||||
5. **Tiny filter script** — new [go/internal/web/static/js/filters.js](../../go/internal/web/static/js/filters.js):
|
||||
- `nameFilter` `input` event: NFD-normalize + lowercase + substring match against `.member-row [data-name]` (or row's first cell text); toggle `display:none` on non-matches.
|
||||
- `fromMonth` / `toMonth` `change` event + `Apply` button: read selected `data-month-idx` range, toggle `month-hidden` class on `[data-month-idx]` `<th>`/`<td>` outside the range.
|
||||
- `All` button: clear filters, restore all rows/cells.
|
||||
- Match Python's behaviour byte-for-byte from [adults.html:864-1051](../../templates/adults.html#L864-L1051) but trimmed of modal/`memberData` calls.
|
||||
- Loaded via `<script src="/static/js/filters.js" defer></script>` from the adults template (or base, if juniors will reuse it — yes, it should be in base or a content-block include).
|
||||
6. **`<a href="/qr?…">` Pay buttons now, modal in M6.5** — the M6.6 milestone adds `/qr`. Until then, hrefs return 404. M6.5 will layer modal preview behaviour on top by wrapping clicks; the markup stays the same. This is the simplest staged rollout.
|
||||
7. **Template helper for QR href** — add a `funcMap` to `Renderer` with `qrHref(account, amount, name, month string) string` (and a periods-list variant) that builds `/qr?account=…&amount=…&message=…` with proper URL encoding. Implementation: `net/url.Values{}.Encode()`. This keeps URL-construction logic out of the template syntax (`html/template` URL escaping isn't enough — query-param building deserves a Go helper).
|
||||
8. **Active-link key** — `Active: "adults"` already set; nav highlighting works as-is (verified in M6.1 smoke test).
|
||||
|
||||
## Files to create / modify
|
||||
|
||||
```
|
||||
go/internal/web/
|
||||
├── api/
|
||||
│ └── handler.go MODIFY — extract ServeAdults body to AssembleAdults(ctx)
|
||||
├── html_handler.go MODIFY — hold *api.Handler; ServeAdults loads + renders
|
||||
├── render.go MODIFY — add AdultsPageData type; add funcMap with qrHref()
|
||||
├── server.go MODIFY — pass *api.Handler into NewHTMLHandler
|
||||
├── html_handler_test.go MODIFY — add adults markup-level assertions w/ stub Sources
|
||||
├── templates/
|
||||
│ └── adults.tmpl MODIFY — replace placeholder w/ full table + filters + sections
|
||||
└── static/
|
||||
└── js/
|
||||
└── filters.js NEW — name + month-range client-side filtering
|
||||
```
|
||||
|
||||
No new packages; no domain or wire-type changes.
|
||||
|
||||
## Key design notes
|
||||
|
||||
- **Reuse `buildAdultsResponse` verbatim** — no parallel "view model" needed. `AdultsResponse` already has every field the template wants. `/adults` and `/api/adults` consume the same struct.
|
||||
- **Extracting `AssembleAdults`** preserves the parity contract: anything that changes the JSON also changes the HTML, by construction. (It also sets up the same pattern for M6.3 juniors and M6.4 payments.)
|
||||
- **Filter UX is DOM-driven**, no JSON hydration. `member_data` / `raw_payments` JSON payloads stay deferred to M6.5 (modal needs them; filters do not).
|
||||
- **`current_month` boundary is server-side** — `AdultsResponse.CurrentMonth` (set from `time.Now().Format("2006-01")` in `loadAll`) is what the template compares `RawMonth` against for `cell-unpaid-current` styling and Pay-button visibility. Same value Python passes through `vm["current_month"]`.
|
||||
- **`html/template` autoescaping is sufficient** for member names, sender, message, etc. — but Pay-button URLs need explicit `url.Values` encoding (Czech names have diacritics and spaces). Hence the `qrHref` funcMap helper.
|
||||
- **Error rendering**: if `AssembleAdults` fails, render the base shell with an error banner inside `content` rather than `http.Error`. Keeps nav visible so the user can navigate away. Match Python's `"No data."` fallback at [app.py:233](../../app.py#L233) for empty results.
|
||||
- **No JS framework, no bundler** — filter script is plain ES2020, ~80 lines, inline-readable. Matches M6.1's "no JS in M6.1" follow-through (we add JS in M6.2, but kept minimal).
|
||||
- **NFD-normalize** in JS via `s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()` to match Python's `unicodedata.normalize('NFD', ...)`. Python ref: [adults.html:868-873](../../templates/adults.html#L868-L873).
|
||||
- **Filter persistence is out of scope** — Python's filters are session-only (no localStorage). Same here.
|
||||
|
||||
## Critical files to read
|
||||
|
||||
- [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) — full view model already built; the template just iterates over `AdultsResponse`.
|
||||
- [go/internal/web/api/types.go:91-122](../../go/internal/web/api/types.go#L91-L122) — `MonthCell`, `TotalCell`, `MemberRow`, `Credit` shapes.
|
||||
- [go/internal/web/api/handler.go:36-96](../../go/internal/web/api/handler.go#L36-L96) — pattern for `loadAll` + `Reconcile` + `buildAdultsResponse`; the bit to refactor into `AssembleAdults`.
|
||||
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — class names available; no edits needed.
|
||||
- [templates/adults.html:489-629](../../templates/adults.html#L489-L629) — markup reference for filter controls, table, totals, credits/debts, unmatched.
|
||||
- [templates/adults.html:864-1051](../../templates/adults.html#L864-L1051) — JS reference for filter behaviour (NFD-normalize, month range hiding, Apply/All).
|
||||
- [tests/test_app.py:50-75](../../tests/test_app.py#L50-L75) — `test_adults_route` in Python, mirror its assertions in Go: status 200, body contains member name + `750/750 CZK (4)` + `Adults Dashboard`, does not contain `OK`.
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end smoke after `make go-build`:
|
||||
|
||||
1. `make web-go &` — server boots, no template-parse errors.
|
||||
2. `curl -i localhost:8080/adults` → `200`, `Content-Type: text/html`.
|
||||
3. Browser at `http://localhost:8080/adults` against real data:
|
||||
- Table renders with one row per adult member, one column per month, totals row at bottom.
|
||||
- Cell colors match `/api/adults` JSON — pick a `Credits` member from JSON, confirm green-ish status; pick a `Debts` member, confirm red-ish.
|
||||
- Credits / Debts / Unmatched sections render with content matching the JSON arrays for those keys.
|
||||
- Per-cell `Pay` buttons appear on hover for past-month unpaid/partial cells; href contains `/qr?account=...&amount=...&message=`.
|
||||
- Per-row "Pay All" button shows for members with `payable_amount > 0`.
|
||||
4. Filters:
|
||||
- Type a partial member name in `#nameFilter` → only matching rows visible. Test with diacritics (e.g. `nemec` matches `Němec`).
|
||||
- Pick `fromMonth=2026-02`, `toMonth=2026-04`, click `Apply` → only those columns visible (table + totals row).
|
||||
- Click `All` → everything restored.
|
||||
5. `curl -s localhost:8080/adults | grep -F 'Adults Dashboard'` matches; `grep -F 'Coming in M6.2'` does **not** match.
|
||||
6. `make go-test` — `html_handler_test.go` adults assertions pass with stub Sources fixture (replicating `test_adults_route` from Python).
|
||||
7. `make go-lint` clean.
|
||||
8. `make parity` still green — `/api/adults` JSON unchanged because `AssembleAdults` extraction is a pure refactor.
|
||||
9. CHANGELOG entry per CLAUDE.md (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||
10. Tick M6.2 in [progress tracker §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114) with the merge commit SHA.
|
||||
|
||||
## Branch & MR
|
||||
|
||||
Branch `feat/go-m6-2-adults-page` per CLAUDE.md branch-per-feature workflow. Commit, push with `-u`, then:
|
||||
|
||||
```bash
|
||||
tea pr create \
|
||||
--title "feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)" \
|
||||
--description "..." \
|
||||
--base main \
|
||||
--head feat/go-m6-2-adults-page
|
||||
```
|
||||
|
||||
Print the PR URL for the user. User merges in Gitea browser.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Period selector missing older months — diagnosis
|
||||
|
||||
## Context
|
||||
|
||||
User reports the "From / To" period selector on the **adults** dashboard now
|
||||
shows **Dec 2025** as the oldest available period. The older production
|
||||
deployment shows Sep+Oct 2025, Nov 2025, Dec 2025+Jan 2026 (merged labels) —
|
||||
i.e. data going back to September 2025. The user wants to know what went
|
||||
sideways. Confirmed: the dropdown options on both Python and Go genuinely
|
||||
start at Dec 2025, not just the default selection — Sep/Oct/Nov 2025 are not
|
||||
in the list at all.
|
||||
|
||||
## Root cause — the live adults sheet header is missing those columns
|
||||
|
||||
The fresh cache files at `tmp/go/attendance_regular_cache.json` (raw rows from
|
||||
Google Sheets, modifiedTime `2026-05-06T22:30:02Z`, cached `2026-05-08T00:26`)
|
||||
contain the actual header row for the adults tab (gid=0):
|
||||
|
||||
```text
|
||||
['FUJ tréninky úterý 20:30-22:00', '', '', '02.12.2025', '09.12.2025',
|
||||
'16.12.2025', '06.01.2026', '13.01.2026', '20.01.2026', '27.01.2026',
|
||||
'03.02.2026', '10.02.2026', '17.02.2026', '24.02.2026', '03.03.2026',
|
||||
'10.03.2026', '17.03.2026', '23.03.2026', '31.03.2026', '13.04.2026',
|
||||
'20.04.2026', '27.04.2026', '04.05.2026', '', '', '']
|
||||
```
|
||||
|
||||
**The first date column in the live adults sheet is `02.12.2025` (Dec 2 2025).**
|
||||
There are no September, October, or November 2025 columns in the header at
|
||||
all. Both backends parse this faithfully (no slicing, no cutoff anywhere) and
|
||||
correctly produce `sortedMonths = ["2025-12", "2026-01", …, "2026-05"]`.
|
||||
|
||||
The juniors sheet (different tab, `JUNIOR_SHEET_GID`) is **fine** — its header
|
||||
still contains `['', 'tier', '', '15.09.2025', '13.10.2025', '20.10.2025',
|
||||
'03.11.2025', '24.11.2025', '10.11.2025', '17.11.2025', '01.12.2025', …]`. So
|
||||
the juniors page still shows Sep+Oct / Nov / Dec+Jan correctly.
|
||||
|
||||
So this is a **data issue in the adults attendance Google Sheet**: at some
|
||||
point between when production's cache was last warmed (showing Sep–Nov) and
|
||||
2026-05-06, somebody (or some action) removed the columns for September,
|
||||
October, and November 2025 from the adults tab header.
|
||||
|
||||
The code is doing exactly what it should. There is no parser regression.
|
||||
|
||||
## What to do
|
||||
|
||||
### 1. Restore the missing date columns in the adults attendance sheet
|
||||
|
||||
The fix lives in Google Sheets, not in the codebase. Options, in order of
|
||||
preference:
|
||||
|
||||
- **(a) Use Sheets version history.** File → Version history → See version
|
||||
history; find a version from before the columns were dropped (anything
|
||||
before about Mar 2026 should still have them). Copy the Sep/Oct/Nov 2025
|
||||
date column headers and the `TRUE/FALSE` cells underneath them back into
|
||||
the current sheet. Only restore the 11 missing date columns; do not
|
||||
full-revert (you'd lose every change since then).
|
||||
- **(b) Pull from the production server's cache.** The production deployment
|
||||
evidently still has the older cache, since its dashboard renders those
|
||||
months. SSH there, copy `tmp/attendance_regular_cache.json`, and you can
|
||||
reconstruct the per-member Sep/Oct/Nov attendance counts from the
|
||||
`data[*][2]` map (keys `"2025-09"`, `"2025-10"`, `"2025-11"`). Re-enter
|
||||
those into the sheet manually as date columns + `TRUE` cells — tedious but
|
||||
deterministic.
|
||||
- **(c) Accept the loss.** If the older columns aren't recoverable, the
|
||||
dashboard correctly reflects what the sheet contains; nothing more to do.
|
||||
|
||||
Which to pick depends on whether those months still need to be billed /
|
||||
reconciled.
|
||||
|
||||
### 2. Restore `ADULT_MERGED_MONTHS` (user confirmed this was unintentional)
|
||||
|
||||
Independent of the sheet issue: commit `1257f0d` (Mar 9 2026) commented out
|
||||
the adult merge mappings. Once the Sep/Oct/Nov columns are back in the sheet,
|
||||
the dashboard would still show them as separate periods instead of the
|
||||
production-style "Sep+Oct 2025" and "Dec 2025+Jan 2026" merged labels.
|
||||
|
||||
User confirmed this was unintentional. Two files to update:
|
||||
|
||||
- [scripts/attendance.py:32-35](scripts/attendance.py#L32-L35) — uncomment
|
||||
the two mappings:
|
||||
|
||||
```python
|
||||
ADULT_MERGED_MONTHS = {
|
||||
"2025-12": "2026-01", # keys are merged into values
|
||||
"2025-09": "2025-10",
|
||||
}
|
||||
```
|
||||
|
||||
- [go/internal/services/membership/sources.go:30](go/internal/services/membership/sources.go#L30) — mirror the same:
|
||||
|
||||
```go
|
||||
var AdultMergedMonths = map[string]string{
|
||||
"2025-12": "2026-01",
|
||||
"2025-09": "2025-10",
|
||||
}
|
||||
```
|
||||
|
||||
After this change, hit `POST /flush-cache` on each backend so the in-process
|
||||
post-processed adults cache is rebuilt with the new mapping.
|
||||
|
||||
### 3. (Optional, separate) Fix the JS auto-default that hides older months
|
||||
|
||||
This is **not** the cause of the user's current symptom (that's the sheet
|
||||
issue), but it will become a UX issue once Sep/Oct/Nov columns are restored:
|
||||
the Python frontend's `defaultFrom = Math.max(0, maxMonthIdx - 4)` will still
|
||||
default the From-selector to ~5 months before the latest column on every page
|
||||
load, hiding restored older months until the user manually picks them.
|
||||
|
||||
- [templates/adults.html:1047](templates/adults.html#L1047) — `var defaultFrom = Math.max(0, maxMonthIdx - 4);`
|
||||
- [templates/juniors.html:1028](templates/juniors.html#L1028) — same line.
|
||||
|
||||
Drop those four lines (`defaultFrom`, `fromSelect.value = defaultFrom`,
|
||||
`toSelect.value = maxMonthIdx`, `applyMonthFilter()`) so the page loads with
|
||||
all non-future months visible — matching the Go side, which only calls
|
||||
`hideFutureMonths()` and leaves From at its first option.
|
||||
|
||||
Recommend bundling this with step 2 since they touch related UI.
|
||||
|
||||
## Verification
|
||||
|
||||
1. **After step 1** — `POST /flush-cache` on Python and Go backends; reload
|
||||
`/adults` on each. Confirm the dropdown now lists Sep/Oct/Nov 2025.
|
||||
2. **After step 2** — reload `/adults`. Confirm the dropdown shows
|
||||
"Sep+Oct 2025" as a single period and "Dec 2025+Jan 2026" as a single
|
||||
period. (Still requires the sheet columns to exist.)
|
||||
3. **After step 3** — reload `/adults` and `/juniors` on Python. Confirm the
|
||||
table renders all non-future months on first load (Sep 2025 through the
|
||||
current month) instead of starting at Dec 2025.
|
||||
4. **Parity check** — `make parity` should report zero diffs between Python
|
||||
and Go on `/api/adults` and `/api/juniors`.
|
||||
|
||||
## Critical files referenced
|
||||
|
||||
- `tmp/go/attendance_regular_cache.json` — current adults sheet rows
|
||||
(evidence: header starts at `02.12.2025`).
|
||||
- `tmp/go/attendance_juniors_cache.json` — current juniors sheet rows
|
||||
(header still has `15.09.2025`).
|
||||
- [scripts/attendance.py](scripts/attendance.py) — `ADULT_MERGED_MONTHS`
|
||||
empty after `1257f0d`; `parse_dates` / `group_by_month` faithful.
|
||||
- [go/internal/services/membership/sources.go](go/internal/services/membership/sources.go) — Go counterpart, same shape.
|
||||
- [templates/adults.html](templates/adults.html), [templates/juniors.html](templates/juniors.html) — JS onload `defaultFrom = -4` issue (step 3).
|
||||
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — Go filter UI (already correct, no changes).
|
||||
@@ -0,0 +1,67 @@
|
||||
# M6.3 — Go-native `/juniors` page
|
||||
|
||||
## Context
|
||||
|
||||
The Go rewrite is in milestone M6 (Go-native HTML frontend). M6.2 shipped the `/adults` page in commit `c85748b`; the [progress tracker](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) line 115 names M6.3 as:
|
||||
|
||||
> `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
||||
|
||||
The Go side already has `buildJuniorsResponse` (M5.2) that produces the full JSON view model, and the JuniorsResponse uses the same `MemberRow`/`MonthCell`/`TotalCell` types as `AdultsResponse`. Critically, `buildJuniorMemberRow` ([go/internal/web/api/build_juniors.go:142](../../srv/personal/fuj-management/go/internal/web/api/build_juniors.go#L142)) already bakes the J/A breakdown (e.g. `"3/500 CZK (4:2J,2A)"`) and the `"?"` sentinel into `MonthCell.Text` — no template-level junior-specific logic is required for the M6.3 table.
|
||||
|
||||
Today `juniors.tmpl` is a 4-line "Coming in M6.3" placeholder and `HTMLHandler.ServeJuniors` ([html_handler.go:35](../../srv/personal/fuj-management/go/internal/web/html_handler.go#L35)) just renders that placeholder. Goal of M6.3: produce a working `/juniors` page that mirrors the structure of `/adults` and reaches feature parity with the Python `/juniors` route.
|
||||
|
||||
## Approach
|
||||
|
||||
Mirror the M6.2 pattern exactly:
|
||||
|
||||
1. **Extract `AssembleJuniors(ctx)`** from the JSON handler so HTML + JSON share one view-model assembly path (the parity contract).
|
||||
2. **Add `JuniorsPageData`** wrapper type next to `AdultsPageData` in `render.go`.
|
||||
3. **Replace `juniors.tmpl`** with an adults-shaped layout. Because `JuniorsResponse` shares the same field names used by `adults.tmpl` (`Months`, `RawMonths`, `Results`, `Totals`, `Credits`, `Debts`, `AttendanceURL`, `PaymentsURL`, `BankAccount`, `CurrentMonth`), the template body is essentially copy/paste with one parity tweak (see "Decisions" below).
|
||||
4. **Wire `HTMLHandler.ServeJuniors`** to call `AssembleJuniors` and render the new template, parallel to `ServeAdults`.
|
||||
|
||||
No new CSS, no new JS — `filters.js` and the `cell-*` classes (lifted in M6.1) already cover juniors.
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| [go/internal/web/api/handler.go](../../srv/personal/fuj-management/go/internal/web/api/handler.go) | Add `AssembleJuniors(ctx) (JuniorsResponse, error)`; refactor existing `ServeJuniors` to call it (mirrors `AssembleAdults` at line 47). |
|
||||
| [go/internal/web/render.go](../../srv/personal/fuj-management/go/internal/web/render.go) | Add `JuniorsPageData` struct (PageData + `Data api.JuniorsResponse` + `Error string`). |
|
||||
| [go/internal/web/html_handler.go](../../srv/personal/fuj-management/go/internal/web/html_handler.go) | Replace stub `ServeJuniors` with the same shape as `ServeAdults` (lines 20–33). |
|
||||
| [go/internal/web/templates/juniors.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/juniors.tmpl) | Replace placeholder with a structurally identical copy of [adults.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/adults.tmpl), with the title swapped to "Juniors Dashboard" and the Unmatched section removed (parity with Python). |
|
||||
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) | Tick `M6.3` once merged, append merge SHA. |
|
||||
| [CHANGELOG.md](../../srv/personal/fuj-management/CHANGELOG.md) | New top-most entry per CLAUDE.md format. |
|
||||
|
||||
## Decisions captured
|
||||
|
||||
- **No "Unmatched Transactions" section on `/juniors`.** Python's [templates/juniors.html](../../srv/personal/fuj-management/templates/juniors.html) renders only Credits + Debts (no Unmatched block). Although `JuniorsResponse.Unmatched` is populated, parity says we don't render it on juniors. The data stays available for the modal in M6.5.
|
||||
- **Reuse `qrHref` / `qrHrefAll` template funcs** from [render.go:35–57](../../srv/personal/fuj-management/go/internal/web/render.go#L35-L57). They are name-only (not adult-specific) and already on `tmplFuncs`.
|
||||
- **Reuse the existing `cell-empty / cell-unpaid / cell-unpaid-current / cell-ok / cell-overridden` cell-class branching** from `adults.tmpl` lines 60–67 verbatim. The "?" sentinel is rendered as plain `{{$cell.Text}}` text — no template branch needed because `buildJuniorMemberRow` produces `"?"` or `"? CZK"` already.
|
||||
- **No template helper extraction** (e.g. shared `_table_section.tmpl`). The two templates will be near-duplicates, but extracting a shared partial now is premature — wait until M6.4 or later when patterns settle. M6.2 didn't extract one either.
|
||||
- **Branch**: `feat/go-m6-3-juniors-page`, MR via `tea pr create` per CLAUDE.md.
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end smoke (parallel-running both backends as in M5/M6.2):
|
||||
|
||||
1. `cd go && make run` (or whatever target boots the Go server on :8080).
|
||||
2. Browser-load `http://localhost:8080/juniors`. Confirm:
|
||||
- Member rows render with cells containing fee + attendance breakdown like `0/500 CZK (3:2J,1A)`.
|
||||
- At least one `"?"` cell visible (any junior with exactly one session in some month).
|
||||
- Filter input narrows rows by name; From/To month selects hide columns via `data-month-idx`.
|
||||
- `Pay` button visible on past-month unpaid cells; clicking links to `/qr?...`. (The QR endpoint itself lands in M6.6 — only the link target is in scope here.)
|
||||
- `Pay All` visible on rows with `Balance < 0`.
|
||||
- TOTAL row sums correctly per month.
|
||||
- Credits + Debts sections render when present; no Unmatched section.
|
||||
3. JSON parity: `make parity` against the M3 fixture corpus must still report zero non-allowlisted diffs for `/api/juniors` (the `AssembleJuniors` extraction must not change wire output).
|
||||
4. Compare side-by-side against Python `/juniors` on :5001 for the same fixture; the table cells should match cell-for-cell modulo the known M5 allowlist.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Modal (`[i]` info button + `#memberModal`) — that is M6.5.
|
||||
- QR endpoint, `/sync-bank`, `/flush-cache`, `/version` pages — M6.6.
|
||||
- Embed.FS deploy verification — M6.7.
|
||||
|
||||
## Plan-file relocation
|
||||
|
||||
Per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) "Plans" section, plan files belong in `docs/plans/YYYY-MM-DD-HHMM-<slug>.md` inside the repo. Plan mode forced this draft into `~/.claude/plans/`; first step after ExitPlanMode is to copy this file to `docs/plans/2026-05-08-HHMM-go-m6-3-juniors-page.md` (resolving the timestamp at that moment) and commit it on the feature branch alongside the implementation.
|
||||
@@ -0,0 +1,215 @@
|
||||
# M6.4 — Go-native `/payments` page (grouped-by-person ledger)
|
||||
|
||||
> Plan-mode note: per project convention this plan should live at
|
||||
> `docs/plans/2026-05-08-1220-go-m6-4-payments-page.md`. The first execution
|
||||
> step after approval is to copy this file there.
|
||||
|
||||
## Context
|
||||
|
||||
The Go port of the Flask app is at milestone **M6.4**: replace the placeholder
|
||||
`/payments` template ([go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl))
|
||||
with a feature-equivalent grouped-by-person ledger view, matching the Python
|
||||
[templates/payments.html](templates/payments.html) page.
|
||||
|
||||
Compared to M6.2 (`/adults`) and M6.3 (`/juniors`), this page is **structurally
|
||||
much simpler**:
|
||||
|
||||
- No filters (no name input, no month-range selects).
|
||||
- No totals / credits / debts / unmatched-as-extra-section.
|
||||
- No JS (no `filters.js`, no modal — modal is M6.5 territory and Python's
|
||||
payments page itself has none).
|
||||
- No QR / Pay buttons.
|
||||
- Single grouping dimension: **by person**, sorted alphabetically, with a
|
||||
synthetic `"Unmatched / Unknown"` bucket appended.
|
||||
- Each person's transactions sorted **newest-first**.
|
||||
- Columns: Date, Amount (right-aligned, green), Purpose, Bank Message.
|
||||
|
||||
The JSON API side is already done (`/api/payments` at
|
||||
[go/internal/web/api/handler.go:78-86](go/internal/web/api/handler.go#L78-L86)),
|
||||
returning a `PaymentsResponse{ GroupedPayments, SortedPeople, AttendanceURL,
|
||||
PaymentsURL }`. The HTML side just needs to consume it.
|
||||
|
||||
## Approach
|
||||
|
||||
Mirror the M6.2/M6.3 pattern: extract an `AssembleX(ctx)` method, add a typed
|
||||
`PageData` wrapper, render a real template, lift the page-specific CSS into the
|
||||
shared stylesheet.
|
||||
|
||||
### 1. Extract `AssemblePayments(ctx) (PaymentsResponse, error)`
|
||||
|
||||
In [go/internal/web/api/handler.go](go/internal/web/api/handler.go), refactor
|
||||
`ServePayments` (currently inlines the load+build at lines 78-86) to mirror
|
||||
`AssembleAdults` / `AssembleJuniors`:
|
||||
|
||||
```go
|
||||
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||
resp, err := h.AssemblePayments(r.Context())
|
||||
if err != nil { h.writeError(w, r, err); return }
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
|
||||
txns, err := h.Sources.LoadTransactions(ctx)
|
||||
if err != nil { return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err) }
|
||||
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
|
||||
}
|
||||
```
|
||||
|
||||
Pure refactor — `make parity` should report zero diffs on `/api/payments`.
|
||||
|
||||
### 2. Add `PaymentsPageData` wrapper
|
||||
|
||||
In [go/internal/web/render.go](go/internal/web/render.go), alongside
|
||||
`AdultsPageData` / `JuniorsPageData`:
|
||||
|
||||
```go
|
||||
type PaymentsPageData struct {
|
||||
PageData
|
||||
Data api.PaymentsResponse
|
||||
Error string
|
||||
}
|
||||
```
|
||||
|
||||
No changes needed to `pageNames` — `"payments"` is already registered.
|
||||
|
||||
### 3. Rewire HTML handler
|
||||
|
||||
Replace [go/internal/web/html_handler.go:50-52](go/internal/web/html_handler.go#L50-L52):
|
||||
|
||||
```go
|
||||
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||
data := PaymentsPageData{ PageData: PageData{Active: "payments", Build: h.build} }
|
||||
resp, err := h.apiHandler.AssemblePayments(r.Context())
|
||||
if err != nil { data.Error = err.Error() } else { data.Data = resp }
|
||||
h.renderer.Render(w, "payments", data)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Replace template stub
|
||||
|
||||
Rewrite [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl)
|
||||
(currently a 5-line placeholder). Structure:
|
||||
|
||||
```gotmpl
|
||||
{{define "title"}}Payments Ledger{{end}}
|
||||
{{define "content"}}
|
||||
<h1>Payments Ledger</h1>
|
||||
<p class="description">
|
||||
All bank transactions from the
|
||||
<a href="{{.Data.PaymentsURL}}" target="_blank">payments sheet</a>,
|
||||
grouped by member. Names are matched against the
|
||||
<a href="{{.Data.AttendanceURL}}" target="_blank">attendance sheet</a>.
|
||||
</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<div class="ledger-container">
|
||||
{{range $person := .Data.SortedPeople}}
|
||||
<div class="member-block">
|
||||
<h2>{{$person}}</h2>
|
||||
<table class="txn-table">
|
||||
<thead><tr>
|
||||
<th class="txn-date">Date</th>
|
||||
<th class="txn-amount">Amount</th>
|
||||
<th class="txn-purpose">Purpose</th>
|
||||
<th class="txn-message">Bank Message</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range $tx := index $.Data.GroupedPayments $person}}
|
||||
<tr>
|
||||
<td class="txn-date">{{$tx.Date}}</td>
|
||||
<td class="txn-amount">{{printf "%.0f" $tx.Amount}} CZK</td>
|
||||
<td class="txn-purpose">{{$tx.Purpose}}</td>
|
||||
<td class="txn-message">{{$tx.Message}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `printf "%.0f"` matches the visual style of integer CZK amounts (Python prints
|
||||
the float as-is, but bank-row amounts are always whole CZK). If the user
|
||||
prefers exact Python parity (`750.0 CZK`), drop the `printf` and use
|
||||
`{{$tx.Amount}}`.
|
||||
- Iterating a `map` requires using `.Data.SortedPeople` to drive order then
|
||||
`index $.Data.GroupedPayments $person` — Go templates don't preserve map
|
||||
insertion order.
|
||||
|
||||
### 5. Add CSS classes to `app.css`
|
||||
|
||||
Append the page-specific rules from [templates/payments.html:111-164](templates/payments.html#L111-L164)
|
||||
to [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css):
|
||||
`.ledger-container`, `.member-block`, `.txn-table`, `.txn-table th/td`,
|
||||
`.txn-date`, `.txn-amount`, `.txn-purpose`, `.txn-message`, `tr:hover`. The
|
||||
terminal-green palette already matches.
|
||||
|
||||
### 6. Unit test
|
||||
|
||||
Add a markup-level test to `go/internal/web/html_handler_test.go` (the file
|
||||
created in M6.2/M6.3) following the same pattern: stub `Sources` returning a
|
||||
fixed transaction list, assert response is HTML 200, assert key strings appear
|
||||
(person names as `<h2>`, "Payments Ledger" `<h1>`, expected amount, "Unmatched
|
||||
/ Unknown" bucket when an unattributed tx exists).
|
||||
|
||||
## Critical files
|
||||
|
||||
| Action | Path |
|
||||
|---------|------|
|
||||
| edit | [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — extract `AssemblePayments` |
|
||||
| edit | [go/internal/web/render.go](go/internal/web/render.go) — add `PaymentsPageData` |
|
||||
| edit | [go/internal/web/html_handler.go](go/internal/web/html_handler.go) — rewire `ServePayments` |
|
||||
| rewrite | [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) — real template |
|
||||
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — append ledger CSS |
|
||||
| edit | `go/internal/web/html_handler_test.go` — payments markup test |
|
||||
|
||||
No changes to `server.go`, `assets.go`, `Sources` interface, `buildPaymentsResponse`,
|
||||
or any domain code — these were completed in M5 / M6.1.
|
||||
|
||||
## Reused existing helpers
|
||||
|
||||
- [go/internal/web/api/build_payments.go](go/internal/web/api/build_payments.go) — `buildPaymentsResponse` (existing, unchanged)
|
||||
- [go/internal/web/api/build_common.go:84-117](go/internal/web/api/build_common.go#L84-L117) — `groupRawPaymentsByPerson` handles canonicalization, comma-splitting, `[?]` stripping, newest-first sort
|
||||
- [go/internal/web/api/handler.go:116-129](go/internal/web/api/handler.go#L116-L129) — `allMemberNames`
|
||||
- [go/internal/web/templates/partials/nav.tmpl](go/internal/web/templates/partials/nav.tmpl) and `footer.tmpl` — already include the `[Payments Ledger]` nav link
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end:
|
||||
|
||||
1. `cd go && make go-build go-test go-lint` — all green.
|
||||
2. `make parity` — confirm the `AssemblePayments` extraction is byte-equal on
|
||||
`/api/payments`.
|
||||
3. `make web-go &` then:
|
||||
- `curl -i localhost:8080/payments | head -30` — HTTP 200, `text/html`.
|
||||
- Browser load `http://localhost:8080/payments`:
|
||||
- "Payments Ledger" heading + description with two clickable sheet links.
|
||||
- Alphabetical member blocks, each with a table.
|
||||
- Amounts right-aligned, green, "CZK" suffix.
|
||||
- Newest-first ordering inside each block.
|
||||
- "Unmatched / Unknown" bucket sorts in alphabetical position (capital U →
|
||||
near end).
|
||||
- Hover on a row darkens the background.
|
||||
- Nav highlights `[Payments Ledger]` as the active link.
|
||||
- Cross-check with Python: `make web-py &` → `http://localhost:5001/payments`
|
||||
should look visually identical except for nav styling.
|
||||
4. `make web-py & make web-go &` then `tea pr create` once committed (per
|
||||
project branch-per-feature workflow).
|
||||
|
||||
## Branching
|
||||
|
||||
Create `feat/go-m6-4-payments-page` off `main`, push, open MR via `tea`.
|
||||
Follow project conventions: Co-Authored-By trailer, CHANGELOG entry once user
|
||||
confirms it works, tick **M6.4** in
|
||||
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:116](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L116)
|
||||
with the merge SHA after the user merges in Gitea.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- **Modal / `[i]` info button** — M6.5 (not present in Python `/payments` either).
|
||||
- **Period selector** — `/payments` has none in Python; not adding one.
|
||||
- **embed.FS deploy verification** — M6.7.
|
||||
- **Shared partial extraction** — payments structurally diverges from
|
||||
adults/juniors enough that there's no obvious common partial to lift; defer.
|
||||
@@ -0,0 +1,288 @@
|
||||
# M6.5 — Member-detail modal JS module (`/adults`, `/juniors`)
|
||||
|
||||
> Plan-mode note: per project convention this plan should live at
|
||||
> `docs/plans/2026-05-08-HHMM-go-m6-5-modal-js.md` (use `date "+%Y-%m-%d-%H%M"`
|
||||
> when copying). The first execution step after approval is to copy this file
|
||||
> there. Branch: `feat/go-m6-5-modal-js` off `main`.
|
||||
|
||||
## Context
|
||||
|
||||
The Go port of the Flask app is at milestone **M6.5**: the `/adults` and
|
||||
`/juniors` pages currently render their tables ([go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl),
|
||||
[go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl))
|
||||
but have no row-click member-detail modal — the feature that lets a user click
|
||||
a member's row to inspect status per month, exception overrides, "other"
|
||||
transactions, the matched payment list, and a debug raw-payments view.
|
||||
|
||||
The Python implementation lives inline in
|
||||
[templates/adults.html:718-993](templates/adults.html#L718-L993)
|
||||
(matching code in [templates/juniors.html](templates/juniors.html) — same
|
||||
modal markup and JS). It uses globals injected via Jinja:
|
||||
`memberData`, `sortedMonths`, `monthLabels`, `rawPaymentsByPerson`. M6.5
|
||||
replaces this with a clean static JS module.
|
||||
|
||||
Per milestone wording — *"fetches `/api/adults` (or juniors), renders
|
||||
status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)"* —
|
||||
and per the user's design choice: the JS module fetches `/api/<page>` **once
|
||||
on page load** and caches the response in a module-scoped variable. Subsequent
|
||||
row clicks render synchronously from cache. This keeps HTML purely
|
||||
server-rendered and reuses the parity-tested JSON contract already shipped in
|
||||
M5.
|
||||
|
||||
All the building blocks are already in place:
|
||||
|
||||
- **JSON API**: `/api/adults` and `/api/juniors` already return
|
||||
`member_data`, `month_labels`, `raw_payments` as nested objects (see
|
||||
[go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42)
|
||||
and the analogous juniors response). Junior `Expected` serialises as `"?"`
|
||||
for the unknown sentinel via custom `MarshalJSON`
|
||||
([go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29)) — JS sees a literal string `"?"`.
|
||||
- **CSS**: `#memberModal`, `.modal-content`, `.modal-section`, `.modal-table`,
|
||||
`.tx-list`, `.tx-item`, `.tx-meta`, `.tx-amount`, `.info-icon`, `.raw-toggle`
|
||||
were lifted whole during M6.1 and live in
|
||||
[go/internal/web/static/css/app.css](go/internal/web/static/css/app.css)
|
||||
(lines 168-487). No CSS work needed.
|
||||
- **Member-row hooks**: `tr.member-row[data-name="..."]` and `td.member-name`
|
||||
are already rendered by both templates.
|
||||
- **JS module convention**: `static/js/filters.js` is loaded with `<script src="/static/js/filters.js" defer>`;
|
||||
M6.5 adds a sibling `static/js/member-detail.js` with the same loading
|
||||
pattern.
|
||||
|
||||
## Approach
|
||||
|
||||
### 1. Add `[i]` info icon + `#memberModal` markup to both templates
|
||||
|
||||
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
|
||||
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl):
|
||||
|
||||
a. Inside the `<td class="member-name">{{$row.Name}}</td>` cell (line 59 of
|
||||
each template), append the icon button:
|
||||
|
||||
```gotmpl
|
||||
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
|
||||
```
|
||||
|
||||
Using a `data-name` attribute and a CSS class (rather than `onclick="..."`)
|
||||
keeps the template free of inline JS — the module wires up the listener
|
||||
itself.
|
||||
|
||||
b. After the closing `</div>` of the table-container / sections, before the
|
||||
`<script>` tag at line 140 (adults) / 120 (juniors), inject the modal
|
||||
markup. Mirror the Python structure verbatim
|
||||
([templates/adults.html:644-707](templates/adults.html#L644-L707)):
|
||||
|
||||
```gotmpl
|
||||
<div id="memberModal" class="modal" onclick="if (event.target === this) this.classList.remove('active')">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modalMemberName">—</span>
|
||||
<span id="modalTier" class="modal-tier"></span>
|
||||
<a href="#" class="modal-close" onclick="event.preventDefault(); document.getElementById('memberModal').classList.remove('active')">[x]</a>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Status by Month</div>
|
||||
<table class="modal-table">
|
||||
<thead><tr><th>Month</th><th>Sessions</th><th>Expected</th><th>Paid</th><th>Status</th></tr></thead>
|
||||
<tbody id="modalStatusBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal-section" id="modalExceptionSection" style="display:none">
|
||||
<div class="modal-section-title">Exception Overrides</div>
|
||||
<div id="modalExceptionList" class="tx-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section" id="modalOtherSection" style="display:none">
|
||||
<div class="modal-section-title">Other Payments</div>
|
||||
<div id="modalOtherList" class="tx-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Matched Transactions</div>
|
||||
<div id="modalTxList" class="tx-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">
|
||||
Raw Payments (debug)
|
||||
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
|
||||
</div>
|
||||
<div id="modalRawList" class="tx-list" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
c. Add the script tag alongside `filters.js`. To tell the module which API
|
||||
endpoint to fetch, use a `data-page` attribute on `body` (or on the
|
||||
filter container — already exists). Simplest: add `data-page="adults"` /
|
||||
`data-page="juniors"` to the existing `<div id="filterContainer">`.
|
||||
The module reads it on load.
|
||||
|
||||
```gotmpl
|
||||
<script src="/static/js/member-detail.js" defer></script>
|
||||
```
|
||||
|
||||
### 2. Create [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js)
|
||||
|
||||
A single shared module — both pages use the same DOM contract, so the same
|
||||
JS works for both. Structure (vanilla ES, no build step, matches `filters.js`
|
||||
style — IIFE, `'use strict'`, no globals leaked):
|
||||
|
||||
```js
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const container = document.getElementById('filterContainer');
|
||||
if (!container) return;
|
||||
const page = container.dataset.page; // "adults" | "juniors"
|
||||
if (!page) return;
|
||||
|
||||
let apiData = null; // cached /api/<page> response
|
||||
let currentMemberName = null;
|
||||
|
||||
// ── Data load ─────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
if (apiData) return apiData;
|
||||
const r = await fetch('/api/' + page);
|
||||
if (!r.ok) throw new Error('failed to fetch /api/' + page);
|
||||
apiData = await r.json();
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// Pre-warm immediately so first click is instant.
|
||||
loadData().catch(err => console.error('[member-detail]', err));
|
||||
|
||||
// ── Modal render ──────────────────────────────────────────────────────
|
||||
async function showMember(name) { /* port of Python showMemberDetails */ }
|
||||
function toggleRawPayments(ev) { /* port */ }
|
||||
function closeModal() { document.getElementById('memberModal').classList.remove('active'); }
|
||||
function navigateMember(dir) { /* port — walk visible .member-row */ }
|
||||
|
||||
// ── Wiring ────────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.info-icon[data-name]').forEach(el => {
|
||||
el.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
showMember(el.dataset.name);
|
||||
});
|
||||
});
|
||||
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
const modal = document.getElementById('memberModal');
|
||||
if (e.key === 'Escape') { closeModal(); return; }
|
||||
if (!modal.classList.contains('active')) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
|
||||
});
|
||||
}());
|
||||
```
|
||||
|
||||
The four `port` functions transcribe Python literally, with two adjustments:
|
||||
|
||||
- `data.tier` — read directly from the API response (already present as
|
||||
`AdultsMemberData.Tier` / `JuniorsMemberData.Tier`).
|
||||
- Junior `expected` may equal the literal string `"?"` (sentinel from
|
||||
`Expected.MarshalJSON`). The status-row formatter must treat string
|
||||
`expected` as "unknown / single attendance" and skip the numeric
|
||||
comparisons — same logic Python uses when it sees `"?"`.
|
||||
|
||||
`navigateMember` walks `document.querySelectorAll('tr.member-row')` filtered
|
||||
by `style.display !== 'none'`, finds the index whose `dataset.name`
|
||||
matches `currentMemberName`, and calls `showMember` on the next/previous
|
||||
visible row. Identical to Python lines 944-962 but uses `dataset.name`
|
||||
instead of parsing `childNodes[0].textContent`.
|
||||
|
||||
### 3. Wire `data-page` attribute
|
||||
|
||||
Append `data-page="adults"` to the `filterContainer` div in `adults.tmpl`,
|
||||
`data-page="juniors"` in `juniors.tmpl`. One-line edit per template.
|
||||
|
||||
### 4. Unit test
|
||||
|
||||
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
|
||||
with assertions that the rendered HTML for `/adults` and `/juniors` contains:
|
||||
|
||||
- `class="info-icon"` with the expected `data-name` per fixture row,
|
||||
- `id="memberModal"`,
|
||||
- `<script src="/static/js/member-detail.js"`,
|
||||
- `data-page="adults"` / `data-page="juniors"` on the filter container.
|
||||
|
||||
These are markup-level checks only — the JS module behaviour is not unit
|
||||
tested in Go (would require headless-browser tooling that is out of scope
|
||||
for this milestone). Manual browser verification covers it.
|
||||
|
||||
## Critical files
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — add `[i]` icon, modal markup, `data-page`, script tag |
|
||||
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
|
||||
| new | `go/internal/web/static/js/member-detail.js` — modal module |
|
||||
| edit | `go/internal/web/html_handler_test.go` — markup assertions |
|
||||
|
||||
No changes to: `app.css` (already complete), `assets.go` (the `embed.FS`
|
||||
glob `static/js/*` already picks up new files), `server.go`, `render.go`,
|
||||
the API handlers, or any domain code.
|
||||
|
||||
## Reused existing infrastructure
|
||||
|
||||
- [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — `/api/adults`, `/api/juniors` already serve the modal's data
|
||||
- [go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42), [go/internal/web/api/juniors.go](go/internal/web/api/juniors.go) — typed responses
|
||||
- [go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29) — `Expected` "?" sentinel marshalling
|
||||
- [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — modal CSS already lifted
|
||||
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — IIFE / `'use strict'` / `data-*` attribute style to match
|
||||
- [templates/adults.html:725-962](templates/adults.html#L725-L962) — Python reference implementation to port (logic, not literal copy)
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end:
|
||||
|
||||
1. `cd go && make go-build go-test go-lint` — all green.
|
||||
2. `make web-go &` — boot Go on `:8080`.
|
||||
3. Browser: `http://localhost:8080/adults`
|
||||
- Click `[i]` next to a member name → modal opens with that member's data.
|
||||
- Status table populated; cells colour-coded (cell-ok / cell-unpaid).
|
||||
- Exception section visible only if member has exceptions; exception
|
||||
amount shown with "*" override style.
|
||||
- "Other Payments" section visible only if member has other-purpose
|
||||
transactions.
|
||||
- Matched transactions list newest-first; raw-payments section starts
|
||||
hidden, `[show]` toggles to `[hide]`.
|
||||
- `Esc` closes the modal; `ArrowDown` / `ArrowUp` walk visible rows
|
||||
(after applying name filter).
|
||||
- Click outside the `.modal-content` closes the modal.
|
||||
- Open DevTools Network tab → reload; confirm one request to `/api/adults`
|
||||
fires on page load (data is pre-warmed), no further requests on row
|
||||
click.
|
||||
4. Browser: `http://localhost:8080/juniors`
|
||||
- Same checks; verify `expected = "?"` rows render the question-mark
|
||||
status without throwing JS errors.
|
||||
5. Cross-check against Python: `make web-py &` → `http://localhost:5001/adults`
|
||||
should look visually equivalent (modulo nav styling) for the same fixture.
|
||||
6. After commit: `tea pr create --base main --head feat/go-m6-5-modal-js`.
|
||||
|
||||
## Branching
|
||||
|
||||
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-5-modal-js` off `main`,
|
||||
commit with `Co-Authored-By` trailer, push with `-u`, open MR via
|
||||
`tea pr create`. Do not merge from CLI. After the user merges in Gitea:
|
||||
|
||||
- Add CHANGELOG entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||
- Tick **M6.5** in
|
||||
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:117](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L117)
|
||||
with the merge SHA.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- **`/qr` modal** — separate `#qrModal` element, lives in M6.6 alongside
|
||||
`/sync-bank`, `/flush-cache`, `/version`.
|
||||
- **Pay buttons inside the modal** — Python's modal does not include
|
||||
Pay-from-modal; current Go pages already render row-level Pay buttons
|
||||
in M6.2/M6.3.
|
||||
- **Modal on `/payments`** — Python `/payments` has no modal, neither does
|
||||
Go (confirmed in M6.4 plan).
|
||||
- **Deep-linking (`/adults#name=Foo`)** — not in Python, not adding.
|
||||
- **Headless-browser JS tests** — out of scope for this milestone; manual
|
||||
browser verification per §Verification.
|
||||
@@ -0,0 +1,124 @@
|
||||
# M6.6 — `/qr`, `/sync-bank`, `/flush-cache`, `/version` (Go parity)
|
||||
|
||||
> On approval, relocate this file to `docs/plans/YYYY-MM-DD-HHMM-go-m6-6-action-pages.md` per the repo's `CLAUDE.md` "Plans" convention before starting work.
|
||||
|
||||
## Context
|
||||
|
||||
The Go-rewrite progress tracker ([2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118)) lists M6.6 as:
|
||||
|
||||
> `/qr, /sync-bank, /flush-cache, /version pages`
|
||||
|
||||
After M6.4 (payments page) and M6.5 (member-detail modal JS), the only remaining pre-deployment HTML/action endpoints in Python that the Go binary doesn't yet implement are these four. The adults/juniors templates already produce `/qr?...` URLs via the `qrHref` / `qrHrefAll` helpers ([render.go:47-71](go/internal/web/render.go#L47-L71)) — the matching handler is the missing piece. `sync` and `flush_cache` templates exist as "Coming in M6.6" stubs and need real markup + working handlers. `/api/version` already serves build metadata; `/version` just needs to alias it (Python parity).
|
||||
|
||||
After M6.6 the M6 gate ("Browser smoke on :8080: all pages render … QR loads, sync/flush work end-to-end") is reachable.
|
||||
|
||||
## Confirmed design choices
|
||||
|
||||
- **`/sync-bank`**: Python-faithful — GET both renders and triggers the sync, captures stdout/stderr into a log buffer, surfaces success/failure via a flag. Matches existing nav `[Sync Bank Data]` link.
|
||||
- **`/version`**: JSON alias of `/api/version`. No HTML page.
|
||||
- **QR library**: `github.com/skip2/go-qrcode`. Default level `qrcode.Medium`, size 256.
|
||||
|
||||
## Scope per endpoint
|
||||
|
||||
### 1. `GET /qr` — PNG only
|
||||
|
||||
Match Python ([app.py:321-356](app.py#L321-L356)) verbatim semantics:
|
||||
|
||||
1. Read `account` (default `cfg.BankAccount`), `amount` (default `"0"`), `message` (default `""`).
|
||||
2. Validate account against `^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`; on miss, silently fall back to `cfg.BankAccount`.
|
||||
3. Parse amount as float64; clamp to `[0, 10_000_000]`; format `%.2f`; on parse error → `"0.00"`.
|
||||
4. Truncate message to 60 runes; strip all `*`.
|
||||
5. If account contains `/`, split into `{number}*BC:{bankcode}`; else use raw.
|
||||
6. SPD payload: `SPD*1.0*ACC:{acc}*AM:{amount}*CC:CZK*MSG:{msg}` (no `BC:` key when IBAN — Python doesn't either).
|
||||
7. `qrcode.Encode(payload, qrcode.Medium, 256)` → write bytes with `Content-Type: image/png`.
|
||||
|
||||
No template, no HTML.
|
||||
|
||||
### 2. `GET /sync-bank` — triggers + renders
|
||||
|
||||
Match Python ([app.py:124-151](app.py#L124-L151)):
|
||||
|
||||
1. Build a `*bytes.Buffer` to capture step output.
|
||||
2. For each step write a header line then call:
|
||||
- `banksync.SyncToSheets(ctx, cfg.PaymentsSheetID, fioClient, sheetsClient, banksync.SyncOpts{From: jan1ThisYear, To: dec31ThisYear, Sort: true})`
|
||||
- `banksync.InferPayments(ctx, cfg.PaymentsSheetID, sheetsClient, sources, banksync.InferOpts{})`
|
||||
- `cacheFlusher.Flush()` — append `"%d cache files deleted"`.
|
||||
3. On any error: append `err.Error()` + a stack trace via `runtime/debug.Stack()`, set `success=false`, continue rendering.
|
||||
4. Render `sync` template with `SyncPageData{ PageData; Output string; Success bool }`.
|
||||
|
||||
The `banksync` package's stdout-printing functions can be redirected by passing an `io.Writer` — confirm during implementation; if they only print to `os.Stdout`, capture by tee'ing through a `slog` handler or briefly swapping `os.Stdout` (or extend the package with a `*Writer` option). **Preferred**: add a `Logger io.Writer` field to `banksync.SyncOpts` / `InferOpts`. This is a minor extension to existing services, not a rewrite.
|
||||
|
||||
### 3. `/flush-cache` — GET form, POST action
|
||||
|
||||
Match Python ([app.py:117-122](app.py#L117-L122)):
|
||||
|
||||
- `GET` → render `flush_cache` with `Flushed=false`.
|
||||
- `POST` → call `cacheFlusher.Flush()`, render with `Flushed=true, Deleted=n`.
|
||||
|
||||
### 4. `GET /version` — JSON
|
||||
|
||||
One-liner: register the existing `api.VersionHandler` (or its inline equivalent in [api/version.go](go/internal/web/api/version.go)) on the `/version` path as well as `/api/version`. Output: `{"tag", "commit", "build_date"}`.
|
||||
|
||||
## Files to create / modify
|
||||
|
||||
### New files
|
||||
|
||||
- **[go/internal/web/templates/qr.tmpl](go/internal/web/templates/qr.tmpl)** — *not actually needed*; remove from plan if `/qr` is PNG-only. (Decision: skip — no template.)
|
||||
- Replace stub **[go/internal/web/templates/sync.tmpl](go/internal/web/templates/sync.tmpl)** with full markup: title, status banner colored by `Success`, `<pre>` of `Output`, "Run again" link.
|
||||
- Replace stub **[go/internal/web/templates/flush_cache.tmpl](go/internal/web/templates/flush_cache.tmpl)** with: form (`POST /flush-cache` → `<button>[Flush Cache]</button>`), success banner when `Flushed`.
|
||||
|
||||
### Modified files
|
||||
|
||||
- **[go/go.mod](go/go.mod)** / `go.sum` — add `github.com/skip2/go-qrcode`.
|
||||
- **[go/internal/web/server.go](go/internal/web/server.go)** — register `GET /qr`, `GET /sync-bank` (replace stub), `GET /flush-cache` (form), `POST /flush-cache` (action), `GET /version`. Widen `Run(...)` signature to accept `fio.Client`, `sheets.Client`, and a `cache.Flusher` — these flow into a new `Deps` struct on `HTMLHandler`.
|
||||
- **[go/internal/web/html_handler.go](go/internal/web/html_handler.go)** — add `ServeQR`, real `ServeSync`, split `ServeFlushCacheGET` / `ServeFlushCachePOST`, add `ServeVersion` (delegates to api package).
|
||||
- **[go/internal/web/render.go](go/internal/web/render.go)** — add `SyncPageData{ PageData; Output string; Success bool }` and `FlushPageData{ PageData; Flushed bool; Deleted int }` view-models. (`pageNames` already includes `"sync"` and `"flush_cache"`; no change.)
|
||||
- **[go/internal/web/qr.go](go/internal/web/qr.go)** *(new file)* — pure helper: `BuildSPD(account, amount, message string, defaultAccount string) (payload string)` + `RenderQRCode(payload string) ([]byte, error)`. Easier to unit-test than a method on the handler.
|
||||
- **[go/internal/services/membership/loader.go](go/internal/services/membership/loader.go)** — add a small `CacheFlusher` interface (`Flush() (int, error)`); have `Sources` aggregate include it. *Alternative considered*: pass `*cache.FileCache` straight into `web.Run`. Choose whichever needs fewer test-stub edits — likely the direct-pass approach, since stubs in tests don't need cache behavior. **Decision: pass `cache.Flusher` directly into `web.Run` as a separate dependency, not through `Sources`.** Simpler, fewer interface changes.
|
||||
- **[go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)** + `infer.go` — accept an optional `io.Writer` (in `SyncOpts.Logger` / `InferOpts.Logger`) and route progress prints through it; default to `os.Stdout` when nil for CLI parity.
|
||||
- **[go/cmd/fuj/main.go](go/cmd/fuj/main.go)** — in the `server` subcommand, build `fio.Client`, `sheets.Client`, and `cache.Flusher` (already constructed inside `membership.NewSources` — expose it or construct a sibling) and pass them into `web.Run`.
|
||||
|
||||
## Reuse (already in the codebase)
|
||||
|
||||
- **Bank sync**: `banksync.SyncToSheets` ([go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)) and `banksync.InferPayments` ([go/internal/services/banksync/infer.go](go/internal/services/banksync/infer.go)) — same calls the CLI `sync` / `infer` subcommands use ([go/cmd/fuj/main.go:184](go/cmd/fuj/main.go#L184), [:222](go/cmd/fuj/main.go#L222)).
|
||||
- **Cache flush**: `(*cache.FileCache).Flush() (int, error)` ([go/internal/io/cache/filecache.go:92](go/internal/io/cache/filecache.go#L92)) — already returns the deleted count.
|
||||
- **Build metadata**: `BuildInfo{Version, Commit, BuildDate}` ([go/internal/web/server.go:28-35](go/internal/web/server.go#L28-L35)) is already plumbed everywhere; `/api/version` JSON shape is already correct ([go/internal/web/api/version.go](go/internal/web/api/version.go)).
|
||||
- **Template render pattern**: `Renderer.Render(w, name, data)` ([go/internal/web/render.go:98-108](go/internal/web/render.go#L98-L108)).
|
||||
- **Test scaffolding**: `web.NewRenderer()` + `httptest.NewRequest/NewRecorder` ([go/internal/web/html_handler_test.go:52-94](go/internal/web/html_handler_test.go#L52-L94)).
|
||||
|
||||
## Tests
|
||||
|
||||
Add to `go/internal/web/html_handler_test.go` (and one new `qr_test.go` for the SPD builder):
|
||||
|
||||
- **`TestQRBuildSPD`** *(unit, in [go/internal/web/qr_test.go](go/internal/web/qr_test.go))* — table-driven: invalid account → fallback; `/`-account → `{num}*BC:{bc}`; IBAN → raw; amount `-1` → `0.00`; amount `99999999` → `9999999.00`; amount `"abc"` → `0.00`; message with `*` stripped; message length-clamped to 60.
|
||||
- **`TestServeQR`** — request `/qr?account=…&amount=10&message=Hi`; assert 200, `Content-Type: image/png`, body starts with PNG magic `\x89PNG\r\n\x1a\n`.
|
||||
- **`TestServeFlushCacheGet`** — assert form rendered, no banner.
|
||||
- **`TestServeFlushCachePost`** — fixture flusher returns 3; assert banner says "3" and `Flushed`.
|
||||
- **`TestServeSync`** — fixture banksync stubs return `(2, nil)` and `(1, nil)`, fixture flusher returns 4; assert page renders, status is success, output contains the section headers.
|
||||
- **`TestServeVersion`** — assert `application/json`, body `{"tag":"v0","commit":"abc1234","build_date":"2026-01-01"}`.
|
||||
- Extend the `TestHTMLHandlerSmoke` table to cover `/qr` and `/version`.
|
||||
|
||||
## Verification (end-to-end)
|
||||
|
||||
1. `cd go && go build ./... && go test ./...`
|
||||
2. `cd go && go run ./cmd/fuj server` — open http://localhost:8080
|
||||
3. Smoke:
|
||||
- **/adults** → click `Pay` next to any unpaid month → modal shows QR image (this exercises `/qr`). Confirm a Czech banking app can scan it (visual check).
|
||||
- Direct `/qr?account=2702008874/2010&amount=700&message=Test` → PNG renders.
|
||||
- **/sync-bank** → click `[Sync Bank Data]` in nav → page shows "Syncing…", then sections + "Inferred Payments", "Cache Flushed", green status banner.
|
||||
- **/flush-cache** → click `[Flush Cache]` → success banner with file count.
|
||||
- **/version** in browser → JSON visible.
|
||||
- Adults / juniors / payments still render (no regressions from `Run` signature change).
|
||||
4. Run the parity diff: `go run ./cmd/parity` — `/api/version` must still match Python.
|
||||
5. Append a CHANGELOG entry per `CLAUDE.md`; tick `M6.6` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118).
|
||||
|
||||
## Branch / MR
|
||||
|
||||
Per `CLAUDE.md` workflow: `feat/go-m6-6-action-pages` off `main`, push with `-u`, open with `tea pr create --base main --head feat/go-m6-6-action-pages`.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Auth / CSRF protection on `/sync-bank` and `/flush-cache` — Python has none either; deferred to M8 hardening.
|
||||
- M6.7 (single-binary embed verification) — separate ticket.
|
||||
- Streaming progress for `/sync-bank` (currently buffered until completion, like Python).
|
||||
- Configurable QR error-correction level / size.
|
||||
@@ -0,0 +1,335 @@
|
||||
# M6.6.1 — Pay-button QR popup modal (`/adults`, `/juniors`)
|
||||
|
||||
> Plan-mode note: per project convention this plan should live at
|
||||
> `docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md`. The first
|
||||
> execution step after approval is to copy this file there.
|
||||
> Branch: `feat/go-m6-6-1-payment-qr-modal` off `main`.
|
||||
|
||||
## Context
|
||||
|
||||
M6.6 ([docs/plans/2026-05-08-1334-go-m6-6-action-pages.md](docs/plans/2026-05-08-1334-go-m6-6-action-pages.md))
|
||||
landed the backend `/qr` PNG endpoint plus `/sync-bank`, `/flush-cache`,
|
||||
`/version`. While doing it, the **client-side** half of the Pay flow was
|
||||
collapsed from a modal-with-details into a plain `<a href="/qr?...">` —
|
||||
clicking Pay now navigates the whole tab to the raw PNG.
|
||||
|
||||
The Python app instead opens an in-page `#qrModal` showing the QR image
|
||||
*plus* labelled details (account, amount, message, title), then closes
|
||||
the modal on outside-click or `[close]`
|
||||
([templates/adults.html:631-648](templates/adults.html#L631-L648),
|
||||
JS at [templates/adults.html:963-993](templates/adults.html#L963-L993)).
|
||||
This is the UX the user expects from the dashboard — staying on the
|
||||
table while displaying the QR is significantly more practical than
|
||||
losing context to a fullscreen image.
|
||||
|
||||
M6.6.1 restores parity: the Pay/Pay-All buttons open an in-page
|
||||
`#qrModal`, identical in structure to the Python original, driven by a
|
||||
new vanilla-JS module `payment-qr.js` that mirrors the `member-detail.js`
|
||||
convention from M6.5.
|
||||
|
||||
All the building blocks are already in place:
|
||||
|
||||
- **`/qr` endpoint** ships PNGs ([go/internal/web/qr.go](go/internal/web/qr.go), tested by [TestServeQR](go/internal/web/html_handler_test.go#L207-L231)).
|
||||
- **CSS** for `#qrModal .modal-content`, `.qr-image`, `.qr-details` is
|
||||
already in [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — lifted during M6.1.
|
||||
- **Template helpers** `qrHref` / `qrHrefAll` ([go/internal/web/render.go:63-85](go/internal/web/render.go#L63-L85))
|
||||
already produce the correct query string + Czech `MM/YYYY` format
|
||||
for the `message=` param. They're useful for sanity (and for any
|
||||
no-JS fallback) but become dead code post-M6.6.1 — see §Out of scope.
|
||||
- **Bank account** is already exposed as `$.Data.BankAccount` to every
|
||||
template that has a Pay button.
|
||||
- **JS module convention**: `member-detail.js` is the M6.5 baseline —
|
||||
IIFE, `'use strict'`, event delegation off `data-*` attributes, no
|
||||
inline `onclick`, no globals leaked.
|
||||
|
||||
## Approach
|
||||
|
||||
### 1. Replace `<a href="{{qrHref ...}}">` with `<button data-...>`
|
||||
|
||||
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
|
||||
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl)
|
||||
(both have identical Pay markup at lines 65 and 72):
|
||||
|
||||
```gotmpl
|
||||
<button type="button" class="pay-btn"
|
||||
data-name="{{$row.Name}}"
|
||||
data-amount="{{$cell.Amount}}"
|
||||
data-month="{{$cell.Month}}"
|
||||
data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||
```
|
||||
|
||||
```gotmpl
|
||||
<button type="button" class="pay-btn"
|
||||
data-name="{{$row.Name}}"
|
||||
data-amount="{{$row.PayableAmount}}"
|
||||
data-month="{{$row.UnpaidPeriods}}"
|
||||
data-raw-month="{{$row.RawUnpaidPeriods}}"
|
||||
data-pay-all="1">Pay All</button>
|
||||
```
|
||||
|
||||
Why `<button type="button">` and not `<a>`: the click no longer
|
||||
navigates — the JS handles it. `type="button"` prevents accidental form
|
||||
submission. A no-JS fallback could be added later by re-introducing
|
||||
`href="{{qrHref ...}}"` and letting the JS call `e.preventDefault()` —
|
||||
deferred until anyone actually asks (see §Out of scope).
|
||||
|
||||
`data-month` carries the human-readable period (e.g. `January 2026`,
|
||||
or `January 2026 + February 2026` for Pay-All) which the modal title
|
||||
displays. `data-raw-month` carries the machine format (`2026-01`,
|
||||
`2026-01+2026-02`) which the JS converts to `MM/YYYY` for the QR
|
||||
message — same conversion Python's `showPayQR` does at
|
||||
[templates/adults.html:966-968](templates/adults.html#L966-L968).
|
||||
`data-pay-all` is purely informational; the JS doesn't actually need
|
||||
it (the same code path handles single + multi-period), but keeping it
|
||||
helps debugging and any future analytics.
|
||||
|
||||
### 2. Add `#qrModal` markup
|
||||
|
||||
Append after the `#memberModal` block in both `adults.tmpl` and
|
||||
`juniors.tmpl` (around line 185 of adults, line 165 of juniors), mirroring
|
||||
[templates/adults.html:631-648](templates/adults.html#L631-L648) but
|
||||
using `class="active"` toggling (the convention M6.5 picked) instead of
|
||||
`style.display = 'block'`:
|
||||
|
||||
```gotmpl
|
||||
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||
</div>
|
||||
<div class="qr-image">
|
||||
<img id="qrImg" src="" alt="Payment QR Code">
|
||||
</div>
|
||||
<div class="qr-details">
|
||||
<div>Account: <span id="qrAccount"></span></div>
|
||||
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||
<div>Message: <span id="qrMessage"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The CSS already targets `#qrModal .modal-content` directly. To make the
|
||||
"hidden by default / visible when `.active`" toggle behave the same
|
||||
way `#memberModal` does, the existing CSS rules for `#memberModal`
|
||||
(`display:none`, `#memberModal.active { display: flex; }`) need a
|
||||
sibling pair for `#qrModal`. Audit `app.css` during implementation:
|
||||
- if `#qrModal` already has both rules, no change.
|
||||
- if it has only `display:none`, add `#qrModal.active { display: flex; }`.
|
||||
- if it has neither, add both.
|
||||
|
||||
This is a 2-line CSS adjustment at most; tracked under §Critical files.
|
||||
|
||||
### 3. Bank account on the page
|
||||
|
||||
The modal needs the bank account string for the `qrAccount` label and
|
||||
for building the `/qr?account=…` URL. Two clean options:
|
||||
|
||||
**(a)** Read it from the `<button>`'s `data-bank-account` attribute.
|
||||
Per-button duplication, but trivially done in templates and keeps the JS
|
||||
module's surface tiny.
|
||||
|
||||
**(b)** Stamp it once on the page as `<body data-bank-account="…">`
|
||||
(or on the existing `#filterContainer`).
|
||||
|
||||
**Decision: (b)** — single stamping site, follows the same pattern
|
||||
M6.5 used for `data-page` on `#filterContainer`. Add
|
||||
`data-bank-account="{{.Data.BankAccount}}"` to `#filterContainer` in
|
||||
both templates (one-line edit each).
|
||||
|
||||
### 4. Create [go/internal/web/static/js/payment-qr.js](go/internal/web/static/js/payment-qr.js)
|
||||
|
||||
A new module loaded with `<script src="/static/js/payment-qr.js" defer></script>`
|
||||
right after `member-detail.js` in both templates. Pure ES, no build
|
||||
step, IIFE, `'use strict'`. Skeleton — port of Python's `showPayQR`
|
||||
([templates/adults.html:963-986](templates/adults.html#L963-L986))
|
||||
plus event wiring:
|
||||
|
||||
```js
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const container = document.getElementById('filterContainer');
|
||||
if (!container) return;
|
||||
const bankAccount = container.dataset.bankAccount || '';
|
||||
|
||||
const modal = document.getElementById('qrModal');
|
||||
const titleEl = document.getElementById('qrTitle');
|
||||
const imgEl = document.getElementById('qrImg');
|
||||
const accountEl = document.getElementById('qrAccount');
|
||||
const amountEl = document.getElementById('qrAmount');
|
||||
const messageEl = document.getElementById('qrMessage');
|
||||
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||
|
||||
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges are converted piece-wise.
|
||||
// Mirrors templates/adults.html:966-968.
|
||||
function toCzechMonth(rawMonth) {
|
||||
return rawMonth.split('+')
|
||||
.map(p => p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'))
|
||||
.join('+');
|
||||
}
|
||||
|
||||
function showQR(name, amount, month, rawMonth) {
|
||||
const numericMonth = toCzechMonth(rawMonth);
|
||||
const message = `${name}: ${numericMonth}`;
|
||||
|
||||
titleEl.innerText = `Payment for ${month}`;
|
||||
accountEl.innerText = bankAccount;
|
||||
amountEl.innerText = amount;
|
||||
messageEl.innerText = message;
|
||||
|
||||
const url = '/qr?'
|
||||
+ 'account=' + encodeURIComponent(bankAccount)
|
||||
+ '&amount=' + encodeURIComponent(amount)
|
||||
+ '&message=' + encodeURIComponent(message);
|
||||
imgEl.src = url;
|
||||
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeQR() { modal.classList.remove('active'); }
|
||||
|
||||
// Event delegation: any .pay-btn click anywhere in the page.
|
||||
document.addEventListener('click', ev => {
|
||||
const btn = ev.target.closest('.pay-btn');
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
showQR(
|
||||
btn.dataset.name,
|
||||
btn.dataset.amount,
|
||||
btn.dataset.month,
|
||||
btn.dataset.rawMonth,
|
||||
);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||
});
|
||||
}());
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **Event delegation** (single listener on `document`) handles both Pay
|
||||
and Pay-All without enumerating buttons. It also survives any future
|
||||
re-render of the table without needing rebinding.
|
||||
- **Coexistence with `member-detail.js`**: `member-detail.js` listens
|
||||
for `Escape` too, but only acts when `#memberModal` is active; the
|
||||
new module similarly only acts when `#qrModal` is active. No collision.
|
||||
- **Pre-warming**: nothing to pre-warm — `/qr` is a tiny on-demand PNG
|
||||
and only fires when the user clicks.
|
||||
- **Inline `onclick`** on `[close]` and outside-click in the markup are
|
||||
carried over from the M6.5 modal style; consistent with `#memberModal`.
|
||||
(Switching the project's modal close pattern to data attributes is a
|
||||
separate cleanup, out of scope here.)
|
||||
|
||||
### 5. Tests
|
||||
|
||||
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
|
||||
with markup-level checks. Headless-browser JS is out of scope (same
|
||||
rationale as M6.5).
|
||||
|
||||
In `TestModalMarkup` (or a sibling test, e.g. `TestPaymentQRMarkup`):
|
||||
|
||||
- Body for `/adults` and `/juniors` contains
|
||||
`<script src="/static/js/payment-qr.js"`.
|
||||
- Body contains `id="qrModal"`, `id="qrImg"`, `id="qrAccount"`,
|
||||
`id="qrAmount"`, `id="qrMessage"`, `id="qrTitle"`.
|
||||
- The fixture row's Pay button is rendered as
|
||||
`<button … class="pay-btn" … data-name="Test Member" data-amount="…" data-raw-month="2026-01"`.
|
||||
- The fixture row's `.pay-btn` does **not** include `href=`.
|
||||
- `#filterContainer` has `data-bank-account="CZ0000000000000000000000"`
|
||||
(matching the test's `fixtureHandler` config).
|
||||
|
||||
### 6. Decide fate of `qrHref` / `qrHrefAll` template helpers
|
||||
|
||||
Once Pay/Pay-All buttons are JS-driven, the helpers in
|
||||
[render.go:63-85](go/internal/web/render.go#L63-L85) have no callers.
|
||||
**Decision: delete them**, plus their entries in `tmplFuncs`. A
|
||||
no-JS fallback is not on the roadmap; if it ever becomes one, the
|
||||
helpers are easy to resurrect from git.
|
||||
|
||||
The unit-test file currently has no direct tests for these helpers —
|
||||
nothing to remove from tests.
|
||||
|
||||
## Critical files
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — Pay/Pay-All → `<button data-…>`; add `#qrModal`; add `data-bank-account` on filter container; add `<script src="/static/js/payment-qr.js" defer>` |
|
||||
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
|
||||
| new | `go/internal/web/static/js/payment-qr.js` — modal module |
|
||||
| edit | [go/internal/web/render.go](go/internal/web/render.go) — drop `qrHref`, `qrHrefAll`, and their `tmplFuncs` entries (and the `net/url`, `strconv` imports if no longer needed) |
|
||||
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — verify / add `#qrModal { display:none; }` + `#qrModal.active { display:flex; }` rules |
|
||||
| edit | [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go) — markup assertions |
|
||||
|
||||
No changes to: `assets.go` (`embed.FS` glob picks up new JS automatically),
|
||||
`server.go`, `html_handler.go`, the `/qr` handler, `cmd/fuj/main.go`, the
|
||||
API handlers, or any domain code.
|
||||
|
||||
## Reused existing infrastructure
|
||||
|
||||
- [go/internal/web/qr.go](go/internal/web/qr.go) — `/qr?account=…&amount=…&message=…` endpoint serves the PNG already
|
||||
- [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — modal CSS already lifted whole
|
||||
- [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js) — IIFE / `'use strict'` / `data-*` style to mirror
|
||||
- [go/internal/web/api/types.go](go/internal/web/api/types.go) — `MemberRow.UnpaidPeriods` and `RawUnpaidPeriods` already populated for Pay-All
|
||||
- [templates/adults.html:631-648,963-986](templates/adults.html#L631-L648) — Python reference for both modal markup and `showPayQR` JS (logic to port, not literal copy)
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end, after copying the plan into `docs/plans/`:
|
||||
|
||||
1. `cd go && make go-build go-test go-lint` — all green.
|
||||
2. `make web-go &` — boot Go on `:8080`.
|
||||
3. Browser: `http://localhost:8080/adults`
|
||||
- Click `Pay` on any unpaid month → `#qrModal` opens with
|
||||
- title `Payment for <Month YYYY>`,
|
||||
- `qrAccount` matching `BANK_ACCOUNT`,
|
||||
- `qrAmount` matching the cell amount,
|
||||
- `qrMessage` formatted as `<Name>: MM/YYYY`,
|
||||
- QR image rendered from `/qr?…` (Network tab shows PNG response).
|
||||
- Tab does **not** navigate; URL stays `/adults`.
|
||||
- Click `Pay All` → modal opens with `+`-joined `MM/YYYY+MM/YYYY` message.
|
||||
- Click outside the modal-content → modal closes.
|
||||
- Click `[close]` → modal closes.
|
||||
- Press `Esc` → modal closes.
|
||||
- Open `[i]` member-detail modal first, then `Esc` → only that closes
|
||||
(verify both modals don't fight each other).
|
||||
4. Browser: `http://localhost:8080/juniors` — same checks. Verify Pay
|
||||
on a `"?"`-expected row doesn't appear (Pay button is conditional on
|
||||
`unpaid`/`partial` cells).
|
||||
5. Cross-check against Python: `make web-py &` →
|
||||
`http://localhost:5001/adults` modal looks visually equivalent for
|
||||
the same fixture (modulo any nav / theme differences already accepted
|
||||
in M6).
|
||||
6. Scan a QR with a Czech banking app (or `zbarimg` on the saved PNG) —
|
||||
payload starts with `SPD*1.0*ACC:` and the message decodes to
|
||||
`<Name>: MM/YYYY`.
|
||||
7. After the user merges in Gitea: append CHANGELOG entry (timestamp via
|
||||
`date "+%Y-%m-%d %H:%M %Z"`); add a new tick line for **M6.6.1** in
|
||||
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||
immediately under the M6.6 line, in the same format as the other
|
||||
sub-bullets.
|
||||
|
||||
## Branching
|
||||
|
||||
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-6-1-payment-qr-modal` off
|
||||
`main`, commit with `Co-Authored-By` trailer, push with `-u`, open MR via
|
||||
`tea pr create`. Do not merge or delete from CLI.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- **Pay button no-JS fallback** — feasible to keep `qrHref` and have
|
||||
the JS `preventDefault` an `<a>`, but no current need. Resurrect the
|
||||
helpers if asked.
|
||||
- **Modal pattern unification** — `#memberModal` and `#qrModal` use
|
||||
inline `onclick` for `[close]` and outside-click. Consistent with
|
||||
M6.5 but worth a follow-up to delegate-event everything.
|
||||
- **Pay-from-modal inside `#memberModal`** — Python doesn't have it, and
|
||||
no one's asked for it on the Go side.
|
||||
- **Per-button QR error-correction / size** — `/qr` is fixed at
|
||||
Medium/256, same as M6.6. Configurable via query param can be added if
|
||||
printing larger physical posters becomes a use case.
|
||||
- **`/payments` Pay buttons** — the payments page is a ledger view; it
|
||||
doesn't render Pay buttons in either backend.
|
||||
@@ -0,0 +1,184 @@
|
||||
# M6.7 — Single-binary embed verification
|
||||
|
||||
## Context
|
||||
|
||||
M6.7 is the final task in M6 (Go-native HTML frontend). Per the
|
||||
[progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md): "Wire
|
||||
`embed.FS` into handlers; verify single-binary deployment includes all
|
||||
assets."
|
||||
|
||||
The wiring is **already in place** from M6.1 onward:
|
||||
|
||||
- [go/internal/web/assets.go](../../go/internal/web/assets.go) declares
|
||||
`//go:embed templates` → `templateFS` and `//go:embed static` → `staticFS`.
|
||||
- [go/internal/web/render.go:66](../../go/internal/web/render.go#L66)
|
||||
parses every page template via `template.New(...).ParseFS(templateFS, ...)`.
|
||||
- [go/internal/web/server.go:48-68](../../go/internal/web/server.go#L48-L68)
|
||||
serves `/static/*` via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
|
||||
- [go/build/Dockerfile](../../go/build/Dockerfile) copies only the compiled
|
||||
binary into the `alpine:3` runtime image — no `templates/` or `static/`
|
||||
directory ever lands beside it.
|
||||
|
||||
What is missing is **proof** the embed is complete and stays complete:
|
||||
|
||||
1. Nothing fails the build/test if a contributor adds a new file under
|
||||
`internal/web/templates/` or `internal/web/static/` that isn't matched
|
||||
by an `//go:embed` glob (or, more realistically, adds a sibling
|
||||
directory like `static/img/` and the glob still picks it up — but a
|
||||
typo'd directive would silently drop it).
|
||||
2. No automated test exercises the `/static/*` route against the embedded
|
||||
FS — current tests in
|
||||
[html_handler_test.go](../../go/internal/web/html_handler_test.go)
|
||||
render templates (which proves `templateFS` is good) but never hit a
|
||||
static URL through the mux.
|
||||
3. The "single binary, no working-dir assets" property is undocumented —
|
||||
if it ever broke, no one would notice until the Docker image started
|
||||
500'ing in prod.
|
||||
|
||||
The intended outcome: a small test file plus a documented manual
|
||||
verification step, after which M6.7 can be ticked and M6 closed.
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Add `go/internal/web/assets_test.go`
|
||||
|
||||
One new file, two tests, no production code changes.
|
||||
|
||||
**Test A — embed completeness regression guard.** Walks the on-disk
|
||||
`templates/` and `static/` directories and asserts every regular file is
|
||||
also present in the corresponding embedded FS. Catches:
|
||||
|
||||
- A new template added without updating the `//go:embed` directive
|
||||
(current globs are `templates` and `static` — recursive by default for
|
||||
directories, so this is a low-probability regression, but the test
|
||||
doubles as living documentation of the contract).
|
||||
- A typo in the directive (e.g. someone renames `static` → `assets` in
|
||||
one place but not the other).
|
||||
|
||||
Implementation sketch:
|
||||
|
||||
```go
|
||||
func TestEmbedCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
diskFS fs.FS // os.DirFS("templates") / os.DirFS("static")
|
||||
embed fs.FS // exported helper or via internal test in package web
|
||||
root string
|
||||
}{...}
|
||||
for _, tc := range cases {
|
||||
_ = fs.WalkDir(tc.diskFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() { return err }
|
||||
embPath := tc.root + "/" + path
|
||||
if _, err := fs.Stat(tc.embed, embPath); err != nil {
|
||||
t.Errorf("file %q on disk but missing in embed.FS: %v", embPath, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Because `templateFS` and `staticFS` are unexported package vars, this
|
||||
test lives in `package web` (not `web_test`) — sibling to
|
||||
[assets.go](../../go/internal/web/assets.go). All the existing handler
|
||||
tests are in `package web_test`; that's fine, this one is internal.
|
||||
|
||||
**Test B — `/static/*` end-to-end via the mux.** Builds an `http.ServeMux`
|
||||
with the same wiring as
|
||||
[server.go:68](../../go/internal/web/server.go#L68), fires httptest
|
||||
requests, asserts:
|
||||
|
||||
- `GET /static/css/app.css` → 200, `Content-Type: text/css; charset=utf-8`,
|
||||
body contains a known string from app.css (e.g. a CSS selector).
|
||||
- `GET /static/js/member-detail.js` → 200, `Content-Type` starts with
|
||||
`text/javascript` or `application/javascript`, body non-empty.
|
||||
- `GET /static/js/payment-qr.js` → 200, body non-empty.
|
||||
- `GET /static/css/missing.css` → 404 (sanity: the file server actually
|
||||
rejects unknown paths instead of returning some default).
|
||||
|
||||
Rather than duplicate the mux assembly, factor a tiny helper (or test the
|
||||
existing mux). The cleanest move: extract `staticHandler()` from
|
||||
[server.go:48-50,68](../../go/internal/web/server.go#L48-L68) into a small
|
||||
exported-from-package function or just `staticFS` / `fs.Sub` helper, and
|
||||
have the test call it. Smallest delta: keep production code unchanged and
|
||||
replicate the two-line wiring inside the test file (acceptable — it's
|
||||
two lines and the test exists precisely to lock that contract).
|
||||
|
||||
### 2. Manual / one-shot verification (no code; documented in plan only)
|
||||
|
||||
Run once locally and tick M6.7. Command transcript:
|
||||
|
||||
```bash
|
||||
make go-build # → ./bin/fuj
|
||||
cp bin/fuj /tmp/fuj-standalone
|
||||
cd /tmp # working dir has no templates/ or static/
|
||||
./fuj-standalone server &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
curl -sf http://localhost:8080/adults | grep -q "Adults Dashboard"
|
||||
curl -sf http://localhost:8080/juniors | grep -q "Juniors"
|
||||
curl -sf http://localhost:8080/payments | grep -q "Payments Ledger"
|
||||
curl -sf -o /tmp/app.css http://localhost:8080/static/css/app.css \
|
||||
&& test -s /tmp/app.css
|
||||
curl -sf -o /tmp/qr.js http://localhost:8080/static/js/payment-qr.js \
|
||||
&& test -s /tmp/qr.js
|
||||
kill $SERVER_PID
|
||||
```
|
||||
|
||||
`fuj server` will fail to talk to Sheets without credentials, so the
|
||||
`/adults` etc. pages will render with the `Error` field set — that's
|
||||
fine; the assertion is that the **template + static asset pipeline** is
|
||||
self-contained, not that data loads. Each curl above only checks for
|
||||
markup present in every render path (header text and stylesheet body).
|
||||
|
||||
### 3. Tracker + changelog
|
||||
|
||||
- Tick `M6.7` in
|
||||
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120](2026-05-03-2349-go-backend-rewrite-progress.md#L120),
|
||||
append the merge SHA on the line.
|
||||
- Mark "Last updated" date and bump milestone status: M6 complete, next is
|
||||
M7.
|
||||
- Append a `CHANGELOG.md` entry per CLAUDE.md convention (`date "+%Y-%m-%d %H:%M %Z"`).
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| [go/internal/web/assets_test.go](../../go/internal/web/assets_test.go) | **new** — two tests (embed completeness + `/static/*` mux) |
|
||||
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) | tick M6.7, bump "last updated" |
|
||||
| [CHANGELOG.md](../../CHANGELOG.md) | new top entry |
|
||||
|
||||
No production source files change. (If extracting the static handler
|
||||
reads cleaner, a 4-line refactor in
|
||||
[server.go](../../go/internal/web/server.go) is acceptable but optional.)
|
||||
|
||||
## Branch + MR
|
||||
|
||||
Per project convention this is a feature, so:
|
||||
|
||||
```bash
|
||||
git checkout -b feat/go-m6-7-embed-verify
|
||||
# … commits …
|
||||
git push -u origin feat/go-m6-7-embed-verify
|
||||
tea pr create --title "feat(go): M6.7 — single-binary embed verification" \
|
||||
--description "<short body referencing M6.7>" --base main \
|
||||
--head feat/go-m6-7-embed-verify
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation:
|
||||
|
||||
1. `make go-test` → green (new `TestEmbedCompleteness` and `TestStaticAssetsServed` pass).
|
||||
2. `make go-lint` → clean.
|
||||
3. Run the manual transcript in §2 above — all curls succeed, no
|
||||
"template not found" or 404 on static assets.
|
||||
4. `make go-build && docker build -f go/build/Dockerfile -t fuj-go:m6-7 go/` →
|
||||
succeeds; `docker run --rm -p 8080:8080 fuj-go:m6-7` serves `/adults`
|
||||
with stylesheet attached (visual smoke test in browser).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Re-architecting how templates are parsed or served.
|
||||
- Compressing / fingerprinting static assets (a separate concern).
|
||||
- Live integration test with real Sheets data — covered later in M7.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Fill-first multi-month payment allocation
|
||||
|
||||
## Context
|
||||
|
||||
Matyáš Thér paid in two transactions:
|
||||
|
||||
| # | Amount | Purpose |
|
||||
|---|--------|---------------------|
|
||||
| 1 | 200 | `2026-02` |
|
||||
| 2 | 550 | `2026-02, 2026-03` |
|
||||
|
||||
Total 750 = his expected fee for the two months (likely 2026-02 = 500, 2026-03 = 250 — junior tier or exception-adjusted). The app currently shows 2026-02 = **566** paid, 2026-03 = **183** paid. The user wants:
|
||||
|
||||
> First use the second payment for the rest of 2026-02 (no more, no less), then put the remainder toward 2026-03.
|
||||
|
||||
Both Python ([scripts/match_payments.py](scripts/match_payments.py)) and Go ([go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go)) have the same bug: when a single transaction's `in_window_share` is less than the sum of in-window expected fees, both fall into a **proportional** branch that splits the new transaction across months in proportion to each month's *total* expected fee — never consulting what prior transactions already paid into earlier months.
|
||||
|
||||
Trace for txn 2:
|
||||
|
||||
- `in_window = [(2026-02, 500), (2026-03, 250)]`, `total_expected = 750`, `in_window_share = 550`.
|
||||
- `550 < 750` → proportional:
|
||||
- 02 alloc = `550 × 500 / 750 = 366.67`
|
||||
- 03 alloc = `550 − 366.67 = 183.33`
|
||||
- Combined with txn 1's 200 → 02 = 566.67, 03 = 183.33 → display `566 / 183`. ✓ matches reported numbers.
|
||||
|
||||
The fix: replace the greedy + proportional branches with a single **fill-first** loop that iterates `in_window` in user-supplied order (already chronological by convention from [scripts/infer_payments.py:151](scripts/infer_payments.py#L151)) and allocates `min(remaining, max(0, expected − paid_so_far))` to each month, with any final surplus going to the credit bucket. This collapses three cases (greedy / proportional / pure overflow) into one and naturally consults the ledger's `paid` field which is already updated by prior transactions in the same reconcile pass.
|
||||
|
||||
The even-split branch (`total_expected == 0`, prepayment before fees known) stays untouched — different semantic, folding it in would silently change behavior.
|
||||
|
||||
## Changes
|
||||
|
||||
### Python — [scripts/match_payments.py:471-498](scripts/match_payments.py#L471-L498)
|
||||
|
||||
Replace both `total_expected > 0` branches (current lines 471–498) with a single unified loop. Keep lines 466–469 above and the even-split fallback below (lines 499–510) as-is.
|
||||
|
||||
```python
|
||||
if total_expected > 0:
|
||||
# Fill-first: iterate in_window in matched_months order (chronological by
|
||||
# convention), allocate min(remaining, deficit) to each month, where
|
||||
# deficit accounts for prior transactions already credited to that month.
|
||||
# Any surplus after all in-window deficits are covered → credit bucket.
|
||||
remaining = in_window_share
|
||||
for m, exp in in_window:
|
||||
paid_so_far = ledger[member_name][m]["paid"]
|
||||
deficit = max(0.0, float(exp) - paid_so_far)
|
||||
alloc = min(remaining, deficit)
|
||||
if alloc <= 0:
|
||||
continue
|
||||
ledger[member_name][m]["paid"] += alloc
|
||||
ledger[member_name][m]["transactions"].append({
|
||||
"amount": alloc,
|
||||
"date": tx["date"],
|
||||
"sender": tx["sender"],
|
||||
"message": tx["message"],
|
||||
"confidence": confidence,
|
||||
})
|
||||
remaining -= alloc
|
||||
if remaining > 0:
|
||||
credits[member_name] = credits.get(member_name, 0) + int(remaining)
|
||||
else:
|
||||
# … existing even-split branch (lines 499–510) unchanged …
|
||||
```
|
||||
|
||||
Note: skipping the `transactions.append` when `alloc <= 0` (e.g. month already fully paid by a prior txn) avoids zero-amount ghost rows in the per-month transaction list. This is a small UI-visible side effect; before committing, grep tests for assertions on `len(transactions)` per month to confirm nothing relies on the current "one row per (txn, month) regardless of alloc" behavior.
|
||||
|
||||
### Go — [go/internal/domain/reconcile/reconcile.go:320-357](go/internal/domain/reconcile/reconcile.go#L320-L357)
|
||||
|
||||
Same shape — replace both `totalExpected > 0` branches. Even-split branch (lines 358–372) stays.
|
||||
|
||||
```go
|
||||
if totalExpected > 0 {
|
||||
// Fill-first; see Python reconcile() for rationale.
|
||||
remaining := inWindowShare
|
||||
for _, mw := range inWindow {
|
||||
md := ledger[memberName][mw.month]
|
||||
deficit := float64(mw.expected) - md.Paid
|
||||
if deficit < 0 {
|
||||
deficit = 0
|
||||
}
|
||||
alloc := remaining
|
||||
if deficit < alloc {
|
||||
alloc = deficit
|
||||
}
|
||||
if alloc <= 0 {
|
||||
continue
|
||||
}
|
||||
md.Paid += alloc
|
||||
md.Transactions = append(md.Transactions, TxEntry{
|
||||
Amount: alloc,
|
||||
Date: tx.Date,
|
||||
Sender: tx.Sender,
|
||||
Message: tx.Message,
|
||||
Confidence: string(m.Confidence),
|
||||
})
|
||||
ledger[memberName][mw.month] = md
|
||||
remaining -= alloc
|
||||
}
|
||||
if remaining > 0 {
|
||||
credits[memberName] += int(remaining)
|
||||
}
|
||||
} else {
|
||||
// … existing even-split branch (lines 358–372) unchanged …
|
||||
}
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
#### Python — [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py)
|
||||
|
||||
1. **Rewrite `test_proportional_underpayment`** ([line 96](tests/test_reconcile_exceptions.py#L96)) — its current assertions (`paid_02 < 750`, `paid_03 < 350`, `paid_04 < 750`, and 02/04 equal allocation) are incompatible with fill-first. Under fill-first with the same fixture (1250 across `02:750, 03:350, 04:750`):
|
||||
- 02: `min(1250, 750) = 750` (full) → remaining 500
|
||||
- 03: `min(500, 350) = 350` (full) → remaining 150
|
||||
- 04: `min(150, 750) = 150` (partial) → remaining 0
|
||||
|
||||
Replace assertions with these exact expected values, rename to `test_underpayment_fills_earliest_first`.
|
||||
|
||||
2. **Add `test_fill_first_across_two_transactions`** — the Matyáš regression:
|
||||
|
||||
```python
|
||||
def test_fill_first_across_two_transactions(self):
|
||||
"""Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
|
||||
members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
|
||||
sorted_months = ['2026-02', '2026-03']
|
||||
tx1 = _tx('Matyáš', '2026-02', 200)
|
||||
tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
|
||||
|
||||
result = reconcile(members, sorted_months, [tx1, tx2])
|
||||
months = result['members']['Matyáš']['months']
|
||||
|
||||
self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2)
|
||||
self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2)
|
||||
```
|
||||
|
||||
3. `test_greedy_exact_match`, `test_greedy_overpayment_goes_to_credit`, `test_single_month_unchanged`, `test_two_members_multi_month` should pass unchanged — fill-first agrees with greedy when payment ≥ total expected.
|
||||
|
||||
#### Go — [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go)
|
||||
|
||||
- Add `TestUnderpaymentFillsEarliestFirst` mirroring the rewritten Python test.
|
||||
- Add `TestFillFirstAcrossTwoTransactions` mirroring the Matyáš scenario.
|
||||
|
||||
#### Parity — [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go)
|
||||
|
||||
Add a fixture for the Matyáš two-transaction case. Since both implementations change together and Python remains canonical, existing parity fixtures should continue to pass; verify after edits.
|
||||
|
||||
### Changelog — [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
Append top entry (use `date "+%Y-%m-%d %H:%M %Z"` at commit time):
|
||||
|
||||
```markdown
|
||||
## 2026-05-11 23:55 CET — fill-first multi-month payment allocation
|
||||
|
||||
- Multi-month payment allocation now fills the earliest in-window deficit first
|
||||
and spills the remainder to later months, accounting for prior transactions'
|
||||
contributions. Previously a single transaction was split proportionally to
|
||||
each month's total expected fee, ignoring earlier payments — surfaced by
|
||||
Matyáš Thér's two-payment 200+550 case showing 566/183 instead of 500/250.
|
||||
- scripts/match_payments.py, go/internal/domain/reconcile/reconcile.go, tests.
|
||||
```
|
||||
|
||||
## Critical files
|
||||
|
||||
- [scripts/match_payments.py](scripts/match_payments.py) — Python reconcile (canonical)
|
||||
- [go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go) — Go reconcile (mirrors Python)
|
||||
- [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py) — rewrite `test_proportional_underpayment` + add Matyáš test
|
||||
- [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go) — add Go tests
|
||||
- [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go) — add parity fixture
|
||||
- [CHANGELOG.md](CHANGELOG.md) — top entry
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make test` — Python unit tests including the rewritten + new fill-first tests.
|
||||
2. `cd go && go test ./internal/domain/reconcile/... ./tests/parity/reconcile/...` — Go unit + parity tests.
|
||||
3. `make web` → load `/adults` or `/juniors` (whichever lists Matyáš Thér) → his 2026-02 row should be fully paid (no shortfall), 2026-03 fully paid, no leftover credit.
|
||||
4. Spot-check one other multi-month-purpose member to make sure fully-covered cases still look right.
|
||||
|
||||
## Branch & MR (per CLAUDE.md)
|
||||
|
||||
1. `git checkout -b fix/fill-first-multi-month-allocation`
|
||||
2. Apply edits + tests + CHANGELOG entry.
|
||||
3. `make test && (cd go && go test ./...)` — both green.
|
||||
4. Commit: `fix(reconcile): fill earliest month deficit first in multi-month allocations` with `Co-Authored-By` trailer.
|
||||
5. `git push -u origin fix/fill-first-multi-month-allocation`
|
||||
6. `tea pr create --title "fix(reconcile): fill earliest month deficit first" --description "<body>" --base main --head fix/fill-first-multi-month-allocation`
|
||||
7. Print MR URL. Do not merge from CLI.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Multi-account Fio sync + switch QR target to 2502035405/2010
|
||||
|
||||
## Context
|
||||
|
||||
The club is opening a second Fio transparent account, **`2502035405/2010`** (IBAN
|
||||
`CZ??...0000002502035405`). Going forward:
|
||||
|
||||
- **Sync** must pull transactions from **both** accounts into the existing
|
||||
payments sheet — the old `2800359168/2010` stays active so historical fees
|
||||
paid to it still reconcile.
|
||||
- **QR codes** generated by `/qr` should default to the **new** account
|
||||
(`2502035405/2010`), so new payers send money there.
|
||||
|
||||
The payments sheet schema and `sync_id` hash stay **unchanged** — no
|
||||
"Source Account" column, no migration, no risk of re-appending historical rows.
|
||||
Bank-ID uniqueness from Fio plus the existing hash inputs are good enough to
|
||||
dedupe across two transparent accounts in practice.
|
||||
|
||||
Implementation order: Go first, then port to Python. Both apps use the same
|
||||
`PaymentsSheetID`, so once Go writes from two accounts the Python code only
|
||||
needs to do the same when invoked.
|
||||
|
||||
### Decisions locked in with the user
|
||||
- **Config style:** hardcoded list of accounts in `config.go` / `config.py`
|
||||
(sheet IDs are already hardcoded; tokens stay env-driven).
|
||||
- **Source tagging:** no schema change, `sync_id` formula unchanged.
|
||||
- **Old account:** kept syncing in parallel; only QR target changes.
|
||||
|
||||
---
|
||||
|
||||
## Go implementation
|
||||
|
||||
### 1. Config — hardcoded `Accounts` list, derived `QRAccount`
|
||||
|
||||
[go/internal/config/config.go](../../go/internal/config/config.go)
|
||||
|
||||
- Replace scalar `BankAccount` + `FioAPIToken` with a hardcoded slice and a
|
||||
primary pointer. Tokens stay env-driven via per-account env names.
|
||||
|
||||
```go
|
||||
// Account describes one Fio bank account we sync from.
|
||||
type Account struct {
|
||||
IBAN string // e.g. "CZ0820100000002502035405"
|
||||
AcctNum string // bare account number, e.g. "2502035405"
|
||||
TokenEnv string // env var name holding the Fio API token (empty token => transparent scraper path)
|
||||
Primary bool // true for the QR-code default account
|
||||
}
|
||||
|
||||
var Accounts = []Account{
|
||||
{IBAN: "CZ0820100000002502035405", AcctNum: "2502035405", TokenEnv: "FIO_API_TOKEN_NEW", Primary: true},
|
||||
{IBAN: "CZ8520100000002800359168", AcctNum: "2800359168", TokenEnv: "FIO_API_TOKEN_OLD"},
|
||||
}
|
||||
```
|
||||
Compute the **real** IBAN check digit for `2502035405` once and bake it in
|
||||
(replace the `85` placeholder above). The same `IBANAccountNum` helper at
|
||||
[config.go:66](../../go/internal/config/config.go#L66) keeps producing
|
||||
`AcctNum`, but having it on the struct avoids re-parsing at every call site.
|
||||
|
||||
- `Config` struct:
|
||||
- Drop `BankAccount` and `FioAPIToken` fields.
|
||||
- Add `QRAccount string` (the IBAN of `Accounts[i].Primary == true`).
|
||||
- Add `LoadedAccounts []LoadedAccount` where each entry pairs an `Account`
|
||||
with the token resolved from `os.Getenv(account.TokenEnv)`.
|
||||
- `Load()` reads each `TokenEnv` and stores tokens with the account. Logs a
|
||||
warning (info-level) if no token is set — that's the transparent-scraper
|
||||
path, which is fine.
|
||||
|
||||
### 2. Sync loop — one `fio.Client` per account, single dedup pass
|
||||
|
||||
[go/internal/services/banksync/sync.go](../../go/internal/services/banksync/sync.go)
|
||||
|
||||
Change `SyncToSheets` to accept a slice of clients instead of one:
|
||||
|
||||
```go
|
||||
func SyncToSheets(
|
||||
ctx context.Context,
|
||||
spreadsheetID string,
|
||||
fioClients []fio.Client, // was: fioClient fio.Client
|
||||
sh sheetsWriter,
|
||||
opts SyncOpts,
|
||||
) (int, error)
|
||||
```
|
||||
|
||||
Inside:
|
||||
|
||||
- Keep steps 1 (read existing IDs) and 2 (date window) as-is.
|
||||
- Step 3 (fetch) becomes a loop: call `FetchTransactions` on each client; tag
|
||||
each `fio.Transaction` with the account it came from for logging only
|
||||
(don't write it to the sheet). Concatenate into one slice `txns`.
|
||||
- Steps 4a–4c (sync_id calc, debug table, row build, dedup) are unchanged —
|
||||
the combined `txns` slice flows through the same code path. Existing
|
||||
`sync_id` collisions across accounts are dropped silently, which is the
|
||||
desired dedup behaviour.
|
||||
- `printFioTable` (dry-run debug) should print an extra column showing the
|
||||
source account so per-account fetches are visible — small change in
|
||||
[fio_table.go](../../go/internal/services/banksync/fio_table.go).
|
||||
|
||||
Logger output: log fetched count per account and the total combined count.
|
||||
|
||||
### 3. Call sites — build clients from the config list
|
||||
|
||||
Both entry points construct `[]fio.Client`, one per `LoadedAccount`:
|
||||
|
||||
- [go/cmd/fuj/main.go:91](../../go/cmd/fuj/main.go#L91) — server `BankSync` action.
|
||||
- [go/cmd/fuj/main.go:200](../../go/cmd/fuj/main.go#L200) — `fuj sync` CLI.
|
||||
|
||||
Helper to add somewhere in `cmd/fuj/main.go` or a new
|
||||
`internal/services/banksync/clients.go`:
|
||||
|
||||
```go
|
||||
func clientsFromConfig(cfg config.Config) []fio.Client {
|
||||
out := make([]fio.Client, 0, len(cfg.LoadedAccounts))
|
||||
for _, a := range cfg.LoadedAccounts {
|
||||
out = append(out, fio.New(a.Token, a.AcctNum, nil))
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
The existing factory at
|
||||
[go/internal/io/fio/client.go:35](../../go/internal/io/fio/client.go#L35)
|
||||
already routes per-account between the API path and the transparent scraper
|
||||
based on whether the token is empty — no change there.
|
||||
|
||||
### 4. QR endpoint — default to `QRAccount`
|
||||
|
||||
[go/internal/web/html_handler.go:129](../../go/internal/web/html_handler.go#L129)
|
||||
and [html_handler.go:135](../../go/internal/web/html_handler.go#L135):
|
||||
swap `h.apiHandler.Config.BankAccount` → `h.apiHandler.Config.QRAccount`.
|
||||
|
||||
API DTOs that surface the payment account in JSON:
|
||||
[build_adults.go:141](../../go/internal/web/api/build_adults.go#L141) and
|
||||
[build_juniors.go:137](../../go/internal/web/api/build_juniors.go#L137) — same
|
||||
swap.
|
||||
|
||||
The fields on the API structs at
|
||||
[adults.go:40](../../go/internal/web/api/adults.go#L40) and
|
||||
[juniors.go:39](../../go/internal/web/api/juniors.go#L39) keep their JSON
|
||||
names so the Python parity binary doesn't see a schema diff.
|
||||
|
||||
### 5. Tests
|
||||
|
||||
- **New** [go/internal/services/banksync/sync_test.go](../../go/internal/services/banksync/sync_test.go):
|
||||
add a test `TestSyncToSheets_MultiAccount` that wires up two fake
|
||||
`fio.Client`s (via the existing
|
||||
[fake.go](../../go/internal/io/fio/fake.go)), one returning a txn unique to
|
||||
it and one returning a txn that duplicates an existing sync_id. Assert:
|
||||
rows from both accounts get appended, the duplicate is dropped.
|
||||
- **Update** [go/internal/web/html_handler_test.go:207](../../go/internal/web/html_handler_test.go#L207)
|
||||
`TestServeQR` and [qr_test.go](../../go/internal/web/qr_test.go)
|
||||
`TestQRBuildSPD` — expected SPD strings now contain the new IBAN by default.
|
||||
- **Update** any config tests asserting `cfg.BankAccount` directly to use
|
||||
`cfg.QRAccount` and the new `LoadedAccounts` slice. (Grep for `BankAccount`
|
||||
under `go/` after the rename.)
|
||||
- Existing fio parser tests
|
||||
([fio_test.go](../../go/internal/io/fio/fio_test.go)) and sync_id test
|
||||
([synch_test.go](../../go/internal/domain/synch/synch_test.go)) need no
|
||||
changes — they don't know about the account list.
|
||||
|
||||
### 6. Verification (Go)
|
||||
|
||||
1. `cd go && go build ./... && go test ./...` — all green.
|
||||
2. `FIO_API_TOKEN_NEW="" FIO_API_TOKEN_OLD="" ./build/fuj sync --dry-run --print-fio-table --days 7`
|
||||
— see fetched transactions from **both** accounts, with NEW/DUP status,
|
||||
without writing.
|
||||
3. `./build/fuj server` then visit
|
||||
`http://localhost:8080/qr?amount=700&message=test` — QR payload SPD must
|
||||
contain the new IBAN `CZ??...2502035405`.
|
||||
4. Real sync once dry-run looks right: `./build/fuj sync --days 30` — confirm
|
||||
in the Google sheet that rows from the old account still appear and no
|
||||
duplicates are written.
|
||||
|
||||
---
|
||||
|
||||
## Python port (after Go is merged)
|
||||
|
||||
The Python app uses the same payments sheet and the same column schema, so
|
||||
the port mirrors the Go change one-for-one. Goal: one round-trip of
|
||||
`make sync-2026` pulls from both accounts.
|
||||
|
||||
### 1. `scripts/config.py`
|
||||
|
||||
- Replace the scalar `BANK_ACCOUNT` constant at
|
||||
[scripts/config.py:25](../../scripts/config.py#L25) with a hardcoded list
|
||||
plus a derived primary:
|
||||
|
||||
```python
|
||||
ACCOUNTS = [
|
||||
{"iban": "CZ0820100000002502035405", "acct_num": "2502035405", "token_env": "FIO_API_TOKEN_NEW", "primary": True},
|
||||
{"iban": "CZ8520100000002800359168", "acct_num": "2800359168", "token_env": "FIO_API_TOKEN_OLD", "primary": False},
|
||||
]
|
||||
BANK_ACCOUNT = next(a["iban"] for a in ACCOUNTS if a["primary"]) # QR / template default
|
||||
```
|
||||
Use the real check-digit IBAN for `2502035405` (the same one baked into Go).
|
||||
|
||||
- Resolve tokens at module load time with `os.environ.get(token_env, "")` and
|
||||
stash them on a parallel `LOADED_ACCOUNTS` list (or attach to the dicts) so
|
||||
other modules don't all re-read env.
|
||||
|
||||
### 2. `scripts/fio_utils.py`
|
||||
|
||||
- [fio_utils.py:106](../../scripts/fio_utils.py#L106) and
|
||||
[fio_utils.py:219](../../scripts/fio_utils.py#L219): drop the hardcoded
|
||||
`"2800359168"` default for `account_id`. Make it a required argument.
|
||||
- `fetch_transactions(date_from, date_to, *, account)` — change signature to
|
||||
take one account dict, return that account's transactions. Internally still
|
||||
routes via `FIO_API_TOKEN` if the account dict has a token, otherwise the
|
||||
transparent scraper at
|
||||
[fio_utils.py:105](../../scripts/fio_utils.py#L105).
|
||||
- Add `fetch_transactions_all(date_from, date_to, accounts=None)` that loops
|
||||
over `accounts` (default `config.LOADED_ACCOUNTS`), calls the per-account
|
||||
fetch, concatenates results. Logs per-account counts.
|
||||
|
||||
### 3. `scripts/sync_fio_to_sheets.py`
|
||||
|
||||
- Where it currently calls `fio_utils.fetch_transactions(...)`, switch to
|
||||
`fio_utils.fetch_transactions_all(...)`. The dedup at
|
||||
[sync_fio_to_sheets.py:62](../../scripts/sync_fio_to_sheets.py#L62) and
|
||||
append at
|
||||
[sync_fio_to_sheets.py:248](../../scripts/sync_fio_to_sheets.py#L248) need
|
||||
no changes — same as in Go, the combined list flows through unchanged.
|
||||
|
||||
### 4. `app.py` (QR + template defaults)
|
||||
|
||||
- `/qr` at [app.py:321](../../app.py#L321) already reads `BANK_ACCOUNT` from
|
||||
config; since the config default now points at the new IBAN, this is a
|
||||
zero-line change in `app.py`.
|
||||
- The `BANK_ACCOUNT` passed into templates at
|
||||
[app.py:180](../../app.py#L180), [app.py:204](../../app.py#L204),
|
||||
[app.py:255](../../app.py#L255), [app.py:291](../../app.py#L291) likewise
|
||||
picks up the new default automatically.
|
||||
|
||||
### 5. Tests
|
||||
|
||||
The Python suite has **no** existing coverage of `fio_utils`,
|
||||
`sync_fio_to_sheets`, or `/qr`. Don't add new tests — keep the port minimal
|
||||
and rely on the Go test suite + manual verification for correctness.
|
||||
|
||||
### 6. Verification (Python)
|
||||
|
||||
1. `make test` — existing suite stays green (the config refactor must not
|
||||
break [tests/test_app.py](../../tests/test_app.py)).
|
||||
2. `make sync-2026` against the real sheet, dry-run first if the script
|
||||
supports it, then real run — confirm rows from both accounts arrive.
|
||||
3. `make web-debug` → open `/qr?amount=700&message=test` → QR payload SPD
|
||||
contains the new IBAN.
|
||||
|
||||
---
|
||||
|
||||
## Branching
|
||||
|
||||
Per `CLAUDE.md`: this is a feature, so `feat/multi-account-bank-sync` off
|
||||
`main`. Two MRs:
|
||||
|
||||
1. `feat/multi-account-bank-sync-go` — Go change end-to-end.
|
||||
2. `feat/multi-account-bank-sync-py` — Python port, opened after (1) is
|
||||
merged.
|
||||
|
||||
Each MR opened via `tea pr create`. CHANGELOG.md entry per MR.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No sheet schema migration.
|
||||
- No change to `sync_id` hash inputs at
|
||||
[domain/synch/synch.go:14](../../go/internal/domain/synch/synch.go#L14) or
|
||||
[sync_fio_to_sheets.py:67](../../scripts/sync_fio_to_sheets.py#L67).
|
||||
- No UI surface for switching the QR account per-request (the existing
|
||||
`?account=` query param on `/qr` keeps working as an override).
|
||||
- No backfill / dedup pass over historical rows.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Fix `/juniors` 500: `int + str` in reconcile allocation
|
||||
|
||||
## Context
|
||||
|
||||
The `/juniors` route crashes with:
|
||||
|
||||
```
|
||||
File ".../scripts/match_payments.py", line 469, in reconcile
|
||||
total_expected = sum(e for _, e in in_window)
|
||||
TypeError: unsupported operand type(s) for +: 'int' and 'str'
|
||||
```
|
||||
|
||||
**Why it happens:** A junior with exactly **1 session** in a month gets an
|
||||
expected fee of the string `"?"` (manual-review marker), set in
|
||||
[attendance.py:107](scripts/attendance.py#L107). That value flows unchanged into
|
||||
the ledger as `ledger[name][m]["expected"]`
|
||||
([match_payments.py:370](scripts/match_payments.py#L370)).
|
||||
|
||||
When a bank payment is matched to such a junior+month, the new **fill-first
|
||||
allocation** block builds `in_window` from those `expected` values and then sums
|
||||
them ([match_payments.py:469](scripts/match_payments.py#L469)) and later does
|
||||
`float(exp)` ([match_payments.py:480](scripts/match_payments.py#L480)) — both
|
||||
blow up on the string `"?"`. Adults never hit this because adults never get
|
||||
`"?"`, which is why only `/juniors` 500s.
|
||||
|
||||
Note the final-balance code at
|
||||
[match_payments.py:511-512](scripts/match_payments.py#L511-L512) **already**
|
||||
handles this defensively (`mdata["expected"] if isinstance(..., int) else 0`).
|
||||
The allocation block added in the recent `fill-first` work simply missed the
|
||||
same guard. The intended convention is clear: a `"?"` (unknown) expected counts
|
||||
as **0** for arithmetic, so any payment landing on such a month becomes surplus
|
||||
/ positive balance rather than filling a deficit.
|
||||
|
||||
## Approach
|
||||
|
||||
Add one small helper and apply the existing "non-numeric expected → 0"
|
||||
convention consistently inside `reconcile()`.
|
||||
|
||||
### 1. Helper (near the top of `scripts/match_payments.py`)
|
||||
|
||||
```python
|
||||
def _expected_amount(value):
|
||||
"""Numeric value of an 'expected' fee; non-numeric markers like '?' → 0."""
|
||||
return value if isinstance(value, (int, float)) else 0
|
||||
```
|
||||
|
||||
### 2. Apply it in the allocation block
|
||||
|
||||
- [match_payments.py:469](scripts/match_payments.py#L469):
|
||||
`total_expected = sum(_expected_amount(e) for _, e in in_window)`
|
||||
- [match_payments.py:480](scripts/match_payments.py#L480):
|
||||
`deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)`
|
||||
|
||||
With expected coerced to 0 for `"?"` months:
|
||||
- A `"?"`-only payment falls into the existing `total_expected == 0` fallback
|
||||
([match_payments.py:495-499](scripts/match_payments.py#L495-L499)) and is
|
||||
recorded as paid (prepayment behaviour) → shows as positive balance.
|
||||
- Mixed with real months, the `"?"` month gets deficit 0 (skipped), real months
|
||||
fill first, surplus → credit. Consistent with line 512.
|
||||
|
||||
### 3. Reuse the helper at line 512 (optional consistency tidy)
|
||||
|
||||
Replace the inline `isinstance(mdata["expected"], int)` check at
|
||||
[match_payments.py:512](scripts/match_payments.py#L512) with
|
||||
`_expected_amount(mdata["paid"]/expected)` form, i.e. use `_expected_amount(...)`.
|
||||
This also closes a latent gap where a **float** exception amount would be treated
|
||||
as 0 (current check only accepts `int`).
|
||||
|
||||
`print_report` (line 552+) iterates adults only, so it's unaffected and needs no
|
||||
change.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make web` and open `http://localhost:5001/juniors` — page renders 200 (was
|
||||
500). Confirm a junior with a single-session `"?"` month who also has a
|
||||
matched payment shows a sensible balance.
|
||||
2. `/adults` still renders unchanged (regression check).
|
||||
3. `make test` — existing reconcile tests still pass; if there is a
|
||||
reconcile test fixture, add a case where a junior month has `expected == "?"`
|
||||
plus a matched transaction and assert no exception + payment counted as
|
||||
surplus.
|
||||
|
||||
## Notes
|
||||
|
||||
- Single-file change: [scripts/match_payments.py](scripts/match_payments.py).
|
||||
- This is a bug fix; per CLAUDE.md a small fix may go straight to `main`, but
|
||||
confirm with the user whether to branch (`fix/junior-expected-question-mark`).
|
||||
- Add a CHANGELOG.md entry once confirmed working.
|
||||
- On execution, copy this plan to `docs/plans/<ts>-junior-expected-fix.md` per
|
||||
the repo plan convention.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Plan: Limit /adults and /juniors to last N months (default 5)
|
||||
|
||||
## Context
|
||||
|
||||
The `/adults` and `/juniors` dashboard tables render one column per month of
|
||||
attendance/fee history. As the season accumulates months, the tables grow wider
|
||||
than the screen. The goal is to show only the **last N months** by default
|
||||
(N = 5), with N configurable via an env var, so the tables fit on screen. This
|
||||
must be implemented in **both** the Python/Flask version and the Go version,
|
||||
keeping their behavior identical.
|
||||
|
||||
### Key correctness requirement
|
||||
|
||||
Member **balances, credits, and debts** must continue to reflect *all* history,
|
||||
not just the visible window. Hiding older columns must not hide older debt.
|
||||
|
||||
This is naturally satisfied because in both codebases the balance math iterates
|
||||
over the full per-member month map produced by reconcile, while only the
|
||||
*column rendering* iterates over the passed-in month list:
|
||||
|
||||
- Python `scripts/views.py`: `settled_balance` / `_settled_balance` /
|
||||
credits / debts loop over `data["months"].items()` (full), whereas columns,
|
||||
totals, and per-row cells loop over `sorted_months`.
|
||||
- Go `build_adults.go` / `build_juniors.go`: `settledBalance(mr, ...)` loops
|
||||
over `mr.Months` (full); columns/totals/cells loop over `sortedMonths`.
|
||||
|
||||
Therefore the correct seam is: run `reconcile()` / `Reconcile()` on the **full**
|
||||
month list, then trim the list to the last N **only for the view-model builder**.
|
||||
The member-details modal also keeps full history because it reads the untrimmed
|
||||
`member_data` / `MemberData`.
|
||||
|
||||
## Approach
|
||||
|
||||
Add a `MONTHS_TO_SHOW` tunable (default 5; `<= 0` means "show all" as an escape
|
||||
hatch). Trim `sorted_months`/`sortedMonths` to the last N immediately before the
|
||||
view-model builder, leaving reconcile on the full list.
|
||||
|
||||
### Python
|
||||
|
||||
1. **`scripts/config.py`** — add, next to the existing `CACHE_TTL_SECONDS`
|
||||
pattern (`int(os.environ.get(...))`):
|
||||
```python
|
||||
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5))
|
||||
```
|
||||
|
||||
2. **`app.py`** — add a small helper (module-level) and apply it in all four
|
||||
routes that build the adults/juniors view models:
|
||||
```python
|
||||
from config import MONTHS_TO_SHOW # add to existing config import
|
||||
|
||||
def _last_n_months(months):
|
||||
return months[-MONTHS_TO_SHOW:] if MONTHS_TO_SHOW > 0 else months
|
||||
```
|
||||
In each route, keep `reconcile(members, sorted_months, ...)` on the full
|
||||
list, then pass the trimmed list to the builder:
|
||||
```python
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
display_months = _last_n_months(sorted_months)
|
||||
vm = build_adults_view_model(members, display_months, result, ...)
|
||||
```
|
||||
Apply to: `adults_view()` (~`app.py:226`), `juniors_view()` (~`app.py:260`),
|
||||
and the JSON twins `/api/adults` (~`app.py:161`) and `/api/juniors`
|
||||
(~`app.py:184`) for parity.
|
||||
|
||||
No changes to `scripts/views.py` — it already derives `months`,
|
||||
`raw_months`, `totals`, and per-row `row.months` from whatever month list it
|
||||
receives, and balances/credits/debts from the full `result`.
|
||||
|
||||
### Go
|
||||
|
||||
1. **`go/internal/config/config.go`** — add `MonthsToShow int` to the `Config`
|
||||
struct (~line 57-67), populate it in `Load()` (~line 80-90) with a new
|
||||
integer helper modeled on `envDuration`:
|
||||
```go
|
||||
func envInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
```
|
||||
```go
|
||||
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
|
||||
```
|
||||
(Note: unlike `envDuration`, accept `<= 0` so it can mean "show all".)
|
||||
|
||||
2. **`go/internal/web/api/handler.go`** — in `AssembleAdults`
|
||||
(lines 50-57) and `AssembleJuniors` (lines 71-78), keep `Reconcile` on the
|
||||
full `sortedMonths`, then trim before the builder:
|
||||
```go
|
||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||
displayMonths := lastNMonths(sortedMonths, h.Config.MonthsToShow)
|
||||
return buildAdultsResponse(members, displayMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
|
||||
```
|
||||
Add a small helper (e.g. in handler.go):
|
||||
```go
|
||||
func lastNMonths(months []string, n int) []string {
|
||||
if n > 0 && len(months) > n {
|
||||
return months[len(months)-n:]
|
||||
}
|
||||
return months
|
||||
}
|
||||
```
|
||||
No changes to `build_adults.go` / `build_juniors.go` or the templates — they
|
||||
already derive `Months`, `RawMonths`, `Totals`, and per-row cells from the
|
||||
passed-in `sortedMonths`, and balances/credits/debts from the full
|
||||
`result.Members[...]`.
|
||||
|
||||
## Critical files
|
||||
|
||||
- `scripts/config.py` — new `MONTHS_TO_SHOW` constant.
|
||||
- `app.py` — trim helper + apply in 4 routes (HTML + JSON, adults + juniors).
|
||||
- `go/internal/config/config.go` — `MonthsToShow` field + `envInt` helper.
|
||||
- `go/internal/web/api/handler.go` — `lastNMonths` helper + apply in
|
||||
`AssembleAdults` / `AssembleJuniors`.
|
||||
|
||||
No template or `views.py` / `build_*.go` changes required.
|
||||
|
||||
## Verification
|
||||
|
||||
Python:
|
||||
- `make test` (and a targeted run, e.g.
|
||||
`PYTHONPATH=scripts:. python -m unittest tests.test_app`).
|
||||
- `make web`, open `/adults` and `/juniors`: confirm exactly 5 month columns by
|
||||
default, and that the **Balance** column / credits / debts are unchanged from
|
||||
before (compare against an untrimmed run, e.g. `MONTHS_TO_SHOW=0`).
|
||||
- `MONTHS_TO_SHOW=3 make web` → 3 columns; `MONTHS_TO_SHOW=0` → all columns.
|
||||
- Spot-check that the month-range filter dropdowns and a member-details modal
|
||||
(full history) still work.
|
||||
|
||||
Go:
|
||||
- `cd go && go build ./... && go test ./...`.
|
||||
- Run the Go server, open `/adults` and `/juniors`: same checks as above
|
||||
(default 5 columns, balances unchanged, `MONTHS_TO_SHOW` env override works).
|
||||
- Confirm Python and Go render the same number of columns and identical
|
||||
balances for the same data.
|
||||
|
||||
## Housekeeping
|
||||
|
||||
- Per `CLAUDE.md`, copy this plan to
|
||||
`docs/plans/YYYY-MM-DD-HHMM-months-to-show.md` during implementation (create
|
||||
`docs/plans/` if missing) and add a `CHANGELOG.md` entry once verified.
|
||||
- Per `CLAUDE.md`, this is a feature → do it on a `feat/months-to-show` branch
|
||||
and open a Gitea MR with `tea`; do not commit to `main`.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Plan: Gitea Action to open a gitops image-update PR for fuj-management
|
||||
|
||||
## Context
|
||||
|
||||
The Go image of this app is built and pushed by the `build-go` job in
|
||||
[.gitea/workflows/build.yaml](.gitea/workflows/build.yaml), tagged
|
||||
`gitea.home.hrajfrisbee.cz/kacerr/fuj-management:<git-tag>-go` (e.g. `0.37-go`).
|
||||
|
||||
Kubernetes manifests live in a **separate** repo,
|
||||
`gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes`. Today, bumping the image in
|
||||
the `fuj-management` Deployment (namespace `fuj`) is a manual edit there.
|
||||
|
||||
We want CI to automate that bump: when a new Go image is built, open a PR against
|
||||
`home-kubernetes` that swaps the image to the freshly built tag — using the
|
||||
`uh-cli gitops deployment update` command. The user reviews/merges that PR in
|
||||
Gitea (matching the existing branch-per-change, merge-in-browser workflow).
|
||||
|
||||
Decisions confirmed with the user:
|
||||
- **Separate workflow file** (not a job inside build.yaml).
|
||||
- **New `GITOPS_TOKEN` secret** for home-kubernetes write + PR access.
|
||||
- **uh-cli version pinned with a default, overridable via env/var/input.**
|
||||
|
||||
## How uh-cli works (from `/Users/jan.novak/srv/go/uh-cli/docs/`)
|
||||
|
||||
- `uh-cli gitops deployment update` clones `--git-repo`, walks `--git-path`
|
||||
recursively for a `kind: Deployment` whose `metadata.name`/`namespace` match,
|
||||
edits the first container image surgically, commits on a new branch
|
||||
`gitops/update-<name>-<timestamp>`, pushes, and **opens the PR itself** via
|
||||
`tea pr create`. PR base is always `main`; title/body are hardcoded (no flags).
|
||||
- Requires on PATH: `git` and `tea` (tea only for the PR flow; `--force` skips it).
|
||||
- Auth: token embedded in the `--git-repo` URL (`https://user:TOKEN@host/...`);
|
||||
`tea login add` for PR creation; git identity via `git config`/env vars.
|
||||
- `--dry-run` prints the unified diff and makes no git changes. Global `-v`
|
||||
(placed **before** the subcommand) enables debug logging on stderr.
|
||||
- Release binaries are named `uh-cli-<version>-linux-amd64` (version includes the
|
||||
`v`), attached to the Gitea release. Latest tag today is **`v0.1.0`**.
|
||||
|
||||
## Change: new workflow `.gitea/workflows/gitops-update.yaml`
|
||||
|
||||
Triggers:
|
||||
- `workflow_run` on `workflows: ["Build and Push"]`, `types: [completed]`, gated
|
||||
to `conclusion == 'success'` — auto-fires after the image build succeeds.
|
||||
- `workflow_dispatch` with inputs: `tag` (git tag without the `-go` suffix, e.g.
|
||||
`0.37`), `dry_run` (boolean, default false), `uh_cli_version` (optional override).
|
||||
|
||||
Single job `gitops-pr`, `runs-on: ubuntu-latest`, in a `container: ubuntu:latest`
|
||||
for a hermetic install (matches the uh-cli CI doc pattern). Steps:
|
||||
|
||||
1. **Install git, curl, ca-certificates, tea** — apt-get + download tea
|
||||
`0.9.2` from `gitea.com/gitea/tea/releases/...` to `/usr/local/bin/tea`.
|
||||
2. **Install uh-cli** — download
|
||||
`https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64`
|
||||
to `/usr/local/bin/uh-cli`.
|
||||
`UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}`.
|
||||
3. **Resolve image tag** — if `workflow_dispatch`, use `inputs.tag`; else use
|
||||
`github.event.workflow_run.head_branch` (the pushed tag name). Output
|
||||
`gitea.home.hrajfrisbee.cz/${{ github.repository }}:<tag>-go`.
|
||||
4. **Configure git identity** — `git config --global user.name/email` for the bot.
|
||||
5. **Authenticate tea** — `tea login add --name ci --url https://gitea.home.hrajfrisbee.cz --token "$GITEA_TOKEN"`.
|
||||
6. **Open image-update PR** — run, with `--dry-run` appended only when the
|
||||
dispatch `dry_run` input is true:
|
||||
```
|
||||
uh-cli -v gitops deployment update \
|
||||
--deployment-name fuj-management \
|
||||
--deployment-namespace fuj \
|
||||
--set-image "<resolved image>" \
|
||||
--git-repo "https://fuj-gitops-bot:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
|
||||
--git-path gitops/home-kubernetes
|
||||
```
|
||||
|
||||
`GITEA_TOKEN` is sourced from `secrets.GITOPS_TOKEN` at job level.
|
||||
|
||||
Job-level guard: `if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}`.
|
||||
|
||||
## Prerequisites (user must set up in Gitea — call out in handoff)
|
||||
|
||||
1. **Create `GITOPS_TOKEN` secret** in the `fuj-management` repo: a Gitea token
|
||||
for a user (`fuj-gitops-bot` or `kacerr`) that has **write + pull-request**
|
||||
access to `kacerr/home-kubernetes`. The username in the `--git-repo` URL must
|
||||
match that token's owner (adjust `fuj-gitops-bot` if using `kacerr`).
|
||||
2. **uh-cli `v0.1.0` release assets must exist** (the `uh-cli-v0.1.0-linux-amd64`
|
||||
binary attached to the release). If not yet published, cut that release in the
|
||||
uh-cli repo first, or set `UH_CLI_VERSION` to a published tag.
|
||||
3. **Confirm the manifest path**: `--git-path gitops/home-kubernetes` must contain
|
||||
the `fuj-management` Deployment; `--deployment-namespace fuj` disambiguates.
|
||||
Cannot verify from this repo — verify against home-kubernetes (narrow the path
|
||||
if uh-cli reports an ambiguity error).
|
||||
|
||||
## Files
|
||||
|
||||
- **New**: `.gitea/workflows/gitops-update.yaml` (the workflow above).
|
||||
- After it works: prepend a `CHANGELOG.md` entry; save this plan to
|
||||
`docs/plans/<ts>-gitops-pr-action.md` per CLAUDE.md convention.
|
||||
|
||||
## Branching
|
||||
|
||||
Feature work → branch `feat/gitops-pr-action` off `main`, commit with the
|
||||
`Co-Authored-By` trailer, push with `-u`, open the MR with
|
||||
`tea pr create --base main --head feat/gitops-pr-action`. Do not merge from CLI.
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Dry run (manual)**: trigger `gitops-update.yaml` via workflow_dispatch with
|
||||
`tag=0.37`, `dry_run=true`. Confirm logs show the unified diff (image line
|
||||
`…:0.37-go`) and `-v` debug milestones; **no PR** is created.
|
||||
2. **Real run (manual)**: re-trigger with `dry_run=false`. Confirm a PR appears in
|
||||
`home-kubernetes` against `main` with the image bump, and the PR URL is printed.
|
||||
3. **Auto-trigger**: push a new git tag to fuj-management → `Build and Push`
|
||||
completes → `gitops-update` fires via `workflow_run` and opens the PR.
|
||||
(Note: `workflow_run`/`head_branch` behavior depends on this Gitea/act_runner
|
||||
version; if it doesn't fire, manual `workflow_dispatch` is the fallback and the
|
||||
plan still delivers the core capability.)
|
||||
@@ -0,0 +1,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.
|
||||
+129
-3
@@ -1,19 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/io/fio"
|
||||
"fuj-management/go/internal/io/sheets"
|
||||
"fuj-management/go/internal/logging"
|
||||
"fuj-management/go/internal/scheduler"
|
||||
"fuj-management/go/internal/services/banksync"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"fuj-management/go/internal/web"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"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=..."
|
||||
@@ -73,15 +79,125 @@ func serverCmd(args []string) {
|
||||
cfg.ServerAddr = *addr
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logger := logging.New(cfg.LogLevel)
|
||||
|
||||
sources, err := membership.NewSources(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fuj server: init sources: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fioClients := buildFioClients(cfg)
|
||||
|
||||
syncTimes, syncLoc, err := config.LoadSyncSchedule()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fuj server: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// 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 ===")
|
||||
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
|
||||
banksync.SyncOpts{From: from, To: to, Sort: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync: %w", err)
|
||||
}
|
||||
fmt.Fprintf(out, "Synced %d new transaction(s).\n\n", n)
|
||||
|
||||
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||
n, err = banksync.InferPayments(ctx, config.PaymentsSheetID, sheetsCli, sources, banksync.InferOpts{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("infer: %w", err)
|
||||
}
|
||||
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
|
||||
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}
|
||||
|
||||
if err := web.Run(logger, cfg.ServerAddr, build); err != nil {
|
||||
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
fs := flag.NewFlagSet("fees", flag.ExitOnError)
|
||||
fs.Usage = func() {
|
||||
@@ -157,7 +273,7 @@ func syncCmd(args []string) {
|
||||
fmt.Fprintf(os.Stderr, "fuj sync: sheets client: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
|
||||
fioClients := buildFioClients(cfg)
|
||||
|
||||
opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable}
|
||||
if *fromStr != "" && *toStr != "" {
|
||||
@@ -173,7 +289,7 @@ func syncCmd(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli, opts)
|
||||
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -234,3 +350,13 @@ Commands:
|
||||
sync Sync Fio transactions to payments sheet
|
||||
infer Infer payment details in payments sheet`)
|
||||
}
|
||||
|
||||
// buildFioClients constructs one fio.Client per configured account.
|
||||
// Each client uses the account's token if available, otherwise the transparent-scraper path.
|
||||
func buildFioClients(cfg config.Config) []fio.Client {
|
||||
clients := make([]fio.Client, 0, len(cfg.LoadedAccounts))
|
||||
for _, a := range cfg.LoadedAccounts {
|
||||
clients = append(clients, fio.New(a.Token, a.AcctNum, nil))
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
// /api/version is intentionally excluded — it returns each binary's own build
|
||||
// identity (tag/commit/build_date), which differs by design between independently
|
||||
// built backends. Pass -route /api/version to inspect it manually.
|
||||
var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
|
||||
|
||||
// defaultAllowlist holds dotted key paths to strip before diffing.
|
||||
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
||||
// template context only (legacy/app.py inject_render_time) and is logged via
|
||||
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
|
||||
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
||||
var defaultAllowlist = []string{"render_time.total"}
|
||||
|
||||
func main() {
|
||||
pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL")
|
||||
goURL := flag.String("go", "http://localhost:8080", "Go backend base URL")
|
||||
route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout")
|
||||
flag.Parse()
|
||||
|
||||
client := &http.Client{Timeout: *timeout}
|
||||
|
||||
targets := allRoutes
|
||||
if *route != "" {
|
||||
targets = []string{*route}
|
||||
}
|
||||
|
||||
matched, diffs, errs := 0, 0, 0
|
||||
|
||||
for _, r := range targets {
|
||||
pyMap, err1 := fetch(client, *pyURL+r)
|
||||
goMap, err2 := fetch(client, *goURL+r)
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
fmt.Printf("=== %s ===\n", r)
|
||||
if err1 != nil {
|
||||
fmt.Printf("ERROR (py): %v\n", err1)
|
||||
}
|
||||
if err2 != nil {
|
||||
fmt.Printf("ERROR (go): %v\n", err2)
|
||||
}
|
||||
fmt.Println()
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
|
||||
scrub(pyMap, defaultAllowlist)
|
||||
scrub(goMap, defaultAllowlist)
|
||||
|
||||
diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())
|
||||
if diff == "" {
|
||||
fmt.Printf("=== %s ===\nOK\n\n", r)
|
||||
matched++
|
||||
} else {
|
||||
fmt.Printf("=== %s ===\n%s\n", r, diff)
|
||||
diffs++
|
||||
}
|
||||
}
|
||||
|
||||
total := len(targets)
|
||||
fmt.Printf("parity: %d/%d routes match", matched, total)
|
||||
if diffs > 0 {
|
||||
fmt.Printf(", %d diff", diffs)
|
||||
if diffs > 1 {
|
||||
fmt.Print("s")
|
||||
}
|
||||
}
|
||||
if errs > 0 {
|
||||
fmt.Printf(", %d error", errs)
|
||||
if errs > 1 {
|
||||
fmt.Print("s")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if errs > 0 {
|
||||
os.Exit(2)
|
||||
}
|
||||
if diffs > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func fetch(client *http.Client, url string) (map[string]any, error) {
|
||||
resp, err := client.Get(url) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return nil, fmt.Errorf("decode JSON: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// scrub removes keys from m whose dotted paths appear in paths.
|
||||
// A bare segment (no dot) deletes a top-level key.
|
||||
// A two-segment path "parent.child" deletes child from m["parent"] if it is a map.
|
||||
func scrub(m map[string]any, paths []string) {
|
||||
for _, path := range paths {
|
||||
parts := strings.SplitN(path, ".", 2)
|
||||
if len(parts) == 1 {
|
||||
delete(m, parts[0])
|
||||
} else {
|
||||
if child, ok := m[parts[0]].(map[string]any); ok {
|
||||
delete(child, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScrubTopLevel(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"build_meta": map[string]any{"tag": "v1"},
|
||||
"other": "keep",
|
||||
}
|
||||
scrub(m, []string{"build_meta"})
|
||||
if _, ok := m["build_meta"]; ok {
|
||||
t.Error("expected build_meta to be removed")
|
||||
}
|
||||
if m["other"] != "keep" {
|
||||
t.Error("expected other to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubNested(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"render_time": map[string]any{
|
||||
"total": "0.123",
|
||||
"breakdown": "fetch:0.1s",
|
||||
},
|
||||
"other": "keep",
|
||||
}
|
||||
scrub(m, []string{"render_time.total"})
|
||||
rt, ok := m["render_time"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("render_time should still be present")
|
||||
}
|
||||
if _, ok := rt["total"]; ok {
|
||||
t.Error("expected render_time.total to be removed")
|
||||
}
|
||||
if rt["breakdown"] != "fetch:0.1s" {
|
||||
t.Error("expected render_time.breakdown to be preserved")
|
||||
}
|
||||
if m["other"] != "keep" {
|
||||
t.Error("expected other to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubMissingPath(t *testing.T) {
|
||||
m := map[string]any{"foo": "bar"}
|
||||
scrub(m, []string{"nonexistent", "render_time.total"})
|
||||
if m["foo"] != "bar" {
|
||||
t.Error("expected foo to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubNestedParentNotMap(t *testing.T) {
|
||||
m := map[string]any{"render_time": "not-a-map"}
|
||||
scrub(m, []string{"render_time.total"})
|
||||
if m["render_time"] != "not-a-map" {
|
||||
t.Error("expected render_time to be unchanged when it is not a map")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ module fuj-management/go
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/invopop/jsonschema v0.14.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
google.golang.org/api v0.278.0
|
||||
@@ -12,6 +15,8 @@ require (
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
@@ -20,11 +25,13 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
|
||||
@@ -4,6 +4,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -27,8 +31,14 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5Ugt
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
|
||||
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -45,6 +55,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/scheduler"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Account describes a Fio bank account.
|
||||
type Account struct {
|
||||
IBAN string // e.g. "CZ0820100000002502035405"
|
||||
AcctNum string // bare account number, e.g. "2502035405"
|
||||
TokenEnv string // env var name holding the optional Fio API token
|
||||
Primary bool // true for the account QR codes default to
|
||||
}
|
||||
|
||||
// LoadedAccount is an Account with its token resolved from the environment.
|
||||
type LoadedAccount struct {
|
||||
Account
|
||||
Token string // value of os.Getenv(Account.TokenEnv); empty → transparent-scraper path
|
||||
}
|
||||
|
||||
// Accounts is the hardcoded list of Fio bank accounts to sync from.
|
||||
// The first entry with Primary=true is used for QR codes.
|
||||
// Tokens are loaded at runtime from each account's TokenEnv.
|
||||
var Accounts = []Account{
|
||||
{IBAN: "CZ0820100000002502035405", AcctNum: "2502035405", TokenEnv: "FIO_API_TOKEN_NEW", Primary: true},
|
||||
{IBAN: "CZ8520100000002800359168", AcctNum: "2800359168", TokenEnv: "FIO_API_TOKEN_OLD"},
|
||||
}
|
||||
|
||||
// Google Sheets IDs — change in code if sheets change (not from env).
|
||||
const (
|
||||
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||
@@ -20,7 +44,7 @@ const (
|
||||
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
|
||||
)
|
||||
|
||||
// CacheSheetMap mirrors scripts/config.py CACHE_SHEET_MAP.
|
||||
// CacheSheetMap mirrors legacy/scripts/config.py CACHE_SHEET_MAP.
|
||||
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
|
||||
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
|
||||
var CacheSheetMap = map[string]string{
|
||||
@@ -31,35 +55,67 @@ var CacheSheetMap = map[string]string{
|
||||
}
|
||||
|
||||
// Config holds all runtime configuration loaded from environment variables.
|
||||
// Mirrors scripts/config.py.
|
||||
// Mirrors legacy/scripts/config.py.
|
||||
type Config struct {
|
||||
CredentialsPath string
|
||||
BankAccount string
|
||||
QRAccount string // IBAN of the primary account used for QR codes
|
||||
LoadedAccounts []LoadedAccount // all accounts to sync, tokens resolved from env
|
||||
CacheDir string
|
||||
CacheTTL time.Duration
|
||||
CacheAPICheckTTL time.Duration
|
||||
DriveTimeout time.Duration
|
||||
LogLevel string
|
||||
FioAPIToken string
|
||||
ServerAddr string
|
||||
MonthsToShow int // show last N month columns; 0 means show all
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying defaults that
|
||||
// match the Python side.
|
||||
func Load() Config {
|
||||
loaded := make([]LoadedAccount, len(Accounts))
|
||||
var qrAccount string
|
||||
for i, a := range Accounts {
|
||||
loaded[i] = LoadedAccount{Account: a, Token: os.Getenv(a.TokenEnv)}
|
||||
if a.Primary {
|
||||
qrAccount = a.IBAN
|
||||
}
|
||||
}
|
||||
return Config{
|
||||
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
||||
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
||||
CacheDir: env("CACHE_DIR", "tmp"),
|
||||
QRAccount: qrAccount,
|
||||
LoadedAccounts: loaded,
|
||||
CacheDir: env("CACHE_DIR", "tmp/go"),
|
||||
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
||||
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
||||
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
||||
LogLevel: env("LOG_LEVEL", "INFO"),
|
||||
FioAPIToken: env("FIO_API_TOKEN", ""),
|
||||
ServerAddr: env("SERVER_ADDR", ":8080"),
|
||||
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// "CZ8520100000002800359168" → "2800359168"
|
||||
// Structure: CZ(2 check)(4 bank code)(16 zero-padded account).
|
||||
@@ -91,3 +147,12 @@ func envDuration(key string, defaultSeconds int) time.Duration {
|
||||
}
|
||||
return time.Duration(defaultSeconds) * time.Second
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
// All expected outputs verified against live Python implementation on 2026-05-05:
|
||||
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
|
||||
// PYTHONPATH=legacy/scripts:legacy python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py.
|
||||
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in legacy/scripts/attendance.py.
|
||||
// Months absent from this map fall back to AdultFeeDefault.
|
||||
var AdultFeeMonthlyRate = map[string]int{
|
||||
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
||||
"2026-01": 750, "2026-02": 750, "2026-03": 350,
|
||||
"2026-04": 700, "2026-05": 700,
|
||||
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 0, "2026-08": 0,
|
||||
"2026-09": 600, "2026-10": 600, "2026-11": 600, "2026-12": 600,
|
||||
}
|
||||
|
||||
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
|
||||
// the given monthKey (format "YYYY-MM").
|
||||
//
|
||||
// 0 practices → 0
|
||||
// 1 practice → AdultFeeSingle (200)
|
||||
// 1 practice → AdultFeeSingle (200), capped at the month's rate so a
|
||||
// 0-rate month is free for everyone
|
||||
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
|
||||
func CalculateFee(attendanceCount int, monthKey string) int {
|
||||
if attendanceCount == 0 {
|
||||
return 0
|
||||
}
|
||||
rate, ok := AdultFeeMonthlyRate[monthKey]
|
||||
if !ok {
|
||||
rate = AdultFeeDefault
|
||||
}
|
||||
if attendanceCount == 1 {
|
||||
return AdultFeeSingle
|
||||
return min(AdultFeeSingle, rate)
|
||||
}
|
||||
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
|
||||
return rate
|
||||
}
|
||||
return AdultFeeDefault
|
||||
}
|
||||
|
||||
@@ -5,24 +5,42 @@ import "testing"
|
||||
func TestCalculateFee(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
||||
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
|
||||
// mustRate returns the configured rate for a month that must be in the map.
|
||||
// It panics immediately if the month is absent — so if a rate entry is ever
|
||||
// removed, the test fails loudly rather than silently comparing against
|
||||
// Go's zero value.
|
||||
mustRate := func(month string) int {
|
||||
r, ok := AdultFeeMonthlyRate[month]
|
||||
if !ok {
|
||||
panic("test month not in AdultFeeMonthlyRate: " + month)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
month string
|
||||
want int
|
||||
}{
|
||||
// Zero attendance always returns 0.
|
||||
{"zero short-circuits", 0, "2026-05", 0},
|
||||
{"zero empty month", 0, "", 0},
|
||||
{"single practice", 1, "2026-05", 200},
|
||||
{"single ignores monthKey", 1, "unknown", 200},
|
||||
{"two practices configured month", 2, "2026-05", 700},
|
||||
{"two practices reduced march", 2, "2026-03", 350},
|
||||
{"two practices early season", 2, "2025-09", 750},
|
||||
{"high count same as two", 5, "2026-05", 700},
|
||||
{"unknown future month falls back", 2, "2027-01", 700},
|
||||
{"empty month falls back", 2, "", 700},
|
||||
// Single practice returns AdultFeeSingle, capped at the month's rate.
|
||||
{"single practice", 1, "2026-05", AdultFeeSingle},
|
||||
{"single unknown month", 1, "unknown", AdultFeeSingle},
|
||||
{"single in free month is free", 1, "2026-07", 0},
|
||||
{"two in free month is free", 2, "2026-08", 0},
|
||||
// Two+ practices for a configured month: must use the map value, not the default.
|
||||
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
|
||||
// when rates are updated — the assertion verifies dispatch logic, not rate values.
|
||||
{"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 {
|
||||
|
||||
@@ -2,11 +2,12 @@ package fees
|
||||
|
||||
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
|
||||
|
||||
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py.
|
||||
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in legacy/scripts/attendance.py.
|
||||
// Months absent from this map fall back to JuniorFeeDefault.
|
||||
var JuniorFeeMonthlyRate = map[string]int{
|
||||
"2025-09": 250,
|
||||
"2026-03": 250,
|
||||
"2026-08": 0,
|
||||
}
|
||||
|
||||
// Expected is the result of a junior fee calculation.
|
||||
@@ -21,17 +22,19 @@ type Expected struct {
|
||||
// the given monthKey (format "YYYY-MM").
|
||||
//
|
||||
// 0 practices → Expected{Value: 0}
|
||||
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?")
|
||||
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?"),
|
||||
// or Expected{Value: 0} when the month's rate is 0
|
||||
// 2+ → Expected{Value: JuniorFeeMonthlyRate[monthKey] or JuniorFeeDefault}
|
||||
func CalculateJuniorFee(attendanceCount int, monthKey string) Expected {
|
||||
if attendanceCount == 0 {
|
||||
return Expected{Value: 0}
|
||||
}
|
||||
if attendanceCount == 1 {
|
||||
rate, ok := JuniorFeeMonthlyRate[monthKey]
|
||||
if !ok {
|
||||
rate = JuniorFeeDefault
|
||||
}
|
||||
if attendanceCount == 1 && rate > 0 {
|
||||
return Expected{Unknown: true}
|
||||
}
|
||||
if rate, ok := JuniorFeeMonthlyRate[monthKey]; ok {
|
||||
return Expected{Value: rate}
|
||||
}
|
||||
return Expected{Value: JuniorFeeDefault}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,40 @@ import "testing"
|
||||
func TestCalculateJuniorFee(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
||||
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
|
||||
// mustRate returns the configured rate for a month that must be in the map.
|
||||
// Panics immediately if the month is absent so a removed entry causes a loud
|
||||
// failure rather than a silent comparison against Go's zero value.
|
||||
mustRate := func(month string) Expected {
|
||||
r, ok := JuniorFeeMonthlyRate[month]
|
||||
if !ok {
|
||||
panic("test month not in JuniorFeeMonthlyRate: " + month)
|
||||
}
|
||||
return Expected{Value: r}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
month string
|
||||
want Expected
|
||||
}{
|
||||
// Zero attendance always returns 0.
|
||||
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
|
||||
{"zero empty month", 0, "", Expected{Value: 0}},
|
||||
// Single practice returns the Unknown sentinel regardless of month.
|
||||
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
|
||||
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
|
||||
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
|
||||
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
|
||||
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
|
||||
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
|
||||
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
|
||||
{"empty month falls back", 2, "", Expected{Value: 500}},
|
||||
// Two+ practices for a configured month: must use the map value, not the default.
|
||||
{"two practices unconfigured month", 2, "2026-05", Expected{Value: JuniorFeeDefault}},
|
||||
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
|
||||
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
|
||||
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
|
||||
// A 0-rate month is free, and a single practice needs no manual review.
|
||||
{"two in free month is free", 2, "2026-08", Expected{Value: 0}},
|
||||
{"single in free month is free", 1, "2026-08", Expected{Value: 0}},
|
||||
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault.
|
||||
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
|
||||
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -1,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
|
||||
|
||||
@@ -14,7 +14,7 @@ var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
|
||||
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
|
||||
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
|
||||
//
|
||||
// Ports scripts/match_payments.py format_date.
|
||||
// Ports legacy/scripts/match_payments.py format_date.
|
||||
func FormatDate(val any) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package matching
|
||||
|
||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
||||
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c '
|
||||
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||
// from match_payments import format_date
|
||||
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
|
||||
// print(repr(format_date(v)))
|
||||
|
||||
@@ -3,6 +3,7 @@ package matching
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -17,12 +18,22 @@ type Transaction struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Members []Match
|
||||
Months []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.
|
||||
//
|
||||
// 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 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;
|
||||
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
|
||||
//
|
||||
// Ports scripts/match_payments.py infer_transaction_details.
|
||||
// Ports legacy/scripts/match_payments.py infer_transaction_details.
|
||||
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
||||
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
||||
|
||||
members := MatchMembers(searchText, memberNames)
|
||||
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
|
||||
|
||||
if len(members) == 0 {
|
||||
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 ym := inferMonthFromDate(tx.Date); ym != "" {
|
||||
months = []string{ym}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package matching
|
||||
|
||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
||||
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 << 'EOF'
|
||||
// PYTHONPATH=legacy/scripts:legacy python3 << 'EOF'
|
||||
// from match_payments import infer_transaction_details
|
||||
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
|
||||
// cases = [
|
||||
@@ -32,6 +32,74 @@ import (
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ var commonSurnames = map[string]bool{
|
||||
// MatchMembers finds members mentioned in text and returns them with a
|
||||
// confidence level of "auto" (reliable) or "review" (needs human verification).
|
||||
//
|
||||
// Algorithm (ported verbatim from scripts/match_payments.py match_members):
|
||||
// Algorithm (ported verbatim from legacy/scripts/match_payments.py match_members):
|
||||
// 1. Exact short-circuit: if any member's full normalized name appears as whole
|
||||
// words in normalize(text), return ONLY those matches as auto. This prevents
|
||||
// nickname "tov" from matching inside surname "ottova".
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package matching
|
||||
|
||||
// Expected values verified against scripts/match_payments.py and
|
||||
// Expected values verified against legacy/scripts/match_payments.py and
|
||||
// tests/test_match_members.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c '
|
||||
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||
// from match_payments import match_members
|
||||
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
|
||||
// cases = [
|
||||
|
||||
@@ -19,7 +19,7 @@ var (
|
||||
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
|
||||
// dropped.
|
||||
//
|
||||
// Ports scripts/match_payments.py _build_name_variants.
|
||||
// Ports legacy/scripts/match_payments.py _build_name_variants.
|
||||
func BuildNameVariants(name string) []string {
|
||||
var nickname string
|
||||
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package matching
|
||||
|
||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
||||
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c '
|
||||
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||
// from match_payments import _build_name_variants
|
||||
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
|
||||
// print(repr(n), "->", _build_name_variants(n))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py.
|
||||
// Package money ports Czech-locale currency parsing from legacy/scripts/infer_payments.py.
|
||||
package money
|
||||
|
||||
import (
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
||||
|
||||
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
|
||||
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount:
|
||||
// as a float64. Mirrors legacy/scripts/infer_payments.py parse_czk_amount:
|
||||
//
|
||||
// - empty input → (0, nil)
|
||||
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
|
||||
|
||||
@@ -8,7 +8,7 @@ func TestParseCZK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||
// from infer_payments import parse_czk_amount
|
||||
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
||||
// "1 500", "1500.00", "1 500.00",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py.
|
||||
// Package reconcile ports the three-phase payment reconciliation from legacy/scripts/match_payments.py.
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
@@ -20,10 +20,13 @@ type Exception struct {
|
||||
Note string
|
||||
}
|
||||
|
||||
// FeeData holds the expected fee and attendance count for one member in one month.
|
||||
// FeeData holds the expected fee and attendance data for one member in one month.
|
||||
type FeeData struct {
|
||||
Expected int
|
||||
IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?")
|
||||
Attendance int
|
||||
JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view
|
||||
AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown
|
||||
}
|
||||
|
||||
// Member is one row from the attendance sheet.
|
||||
@@ -39,11 +42,15 @@ type Member struct {
|
||||
type Transaction struct {
|
||||
Date string
|
||||
Amount float64
|
||||
ManualFix string // "manual fix" column; non-empty disables re-inference
|
||||
Person string // comma-separated canonical names (empty → use inference)
|
||||
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
||||
InferredAmount *float64 // nil → fall back to Amount
|
||||
Sender string
|
||||
VS string // Variabilní symbol (Czech variable payment symbol)
|
||||
Message string
|
||||
BankID string
|
||||
SyncID string
|
||||
UserID string
|
||||
}
|
||||
|
||||
@@ -69,8 +76,11 @@ type OtherEntry struct {
|
||||
// MonthData is the ledger state for one member in one month.
|
||||
type MonthData struct {
|
||||
Expected int
|
||||
IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions
|
||||
OriginalExpected int
|
||||
AttendanceCount int
|
||||
JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view
|
||||
AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown
|
||||
Exception *Exception
|
||||
Paid float64
|
||||
Transactions []TxEntry
|
||||
@@ -95,7 +105,7 @@ var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||
|
||||
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
||||
// used to resolve Person-column values that drift from canonical attendance-sheet names.
|
||||
// Ports scripts/match_payments.py canonical_member_key.
|
||||
// Ports legacy/scripts/match_payments.py canonical_member_key.
|
||||
func canonicalMemberKey(name string) string {
|
||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||
}
|
||||
@@ -105,15 +115,16 @@ type monthExpected struct {
|
||||
expected int
|
||||
}
|
||||
|
||||
// Reconcile matches transactions to members and months using three allocation phases:
|
||||
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
|
||||
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
|
||||
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
||||
// Reconcile matches transactions to members and months using two allocation phases:
|
||||
// 1. Fill-first: iterate matched months in user-supplied order, allocating min(remaining,
|
||||
// deficit) to each month where deficit = expected − already-paid. Surplus → credit.
|
||||
// Handles both the "greedy" (payment covers all) and "partial" cases in one pass.
|
||||
// 2. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
||||
//
|
||||
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
||||
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
||||
//
|
||||
// Ports scripts/match_payments.py reconcile.
|
||||
// Ports legacy/scripts/match_payments.py reconcile.
|
||||
func Reconcile(
|
||||
members []Member,
|
||||
sortedMonths []string,
|
||||
@@ -173,8 +184,11 @@ func Reconcile(
|
||||
|
||||
ledger[name][m] = MonthData{
|
||||
Expected: expected,
|
||||
IsUnknown: fd.IsUnknown,
|
||||
OriginalExpected: originalExpected,
|
||||
AttendanceCount: attendanceCount,
|
||||
JuniorAttendance: fd.JuniorAttendance,
|
||||
AdultAttendance: fd.AdultAttendance,
|
||||
Exception: exInfo,
|
||||
Paid: 0,
|
||||
Transactions: []TxEntry{},
|
||||
@@ -189,7 +203,7 @@ func Reconcile(
|
||||
personStr := strings.TrimSpace(tx.Person)
|
||||
purposeStr := strings.TrimSpace(tx.Purpose)
|
||||
personStr = questionMarkRe.ReplaceAllString(personStr, "")
|
||||
isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:")
|
||||
isOther := matching.IsOtherPurpose(purposeStr)
|
||||
|
||||
var matchedMembers []matching.Match
|
||||
var matchedMonths []string
|
||||
@@ -235,6 +249,11 @@ func Reconcile(
|
||||
matchedMembers = inferred.Members
|
||||
matchedMonths = inferred.Months
|
||||
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 {
|
||||
@@ -304,34 +323,26 @@ func Reconcile(
|
||||
totalExpected += mw.expected
|
||||
}
|
||||
|
||||
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
|
||||
// Greedy: payment covers all expected fees; overflow → credit
|
||||
credits[memberName] += int(inWindowShare - float64(totalExpected))
|
||||
for _, mw := range inWindow {
|
||||
alloc := float64(mw.expected)
|
||||
md := ledger[memberName][mw.month]
|
||||
md.Paid += alloc
|
||||
md.Transactions = append(md.Transactions, TxEntry{
|
||||
Amount: alloc,
|
||||
Date: tx.Date,
|
||||
Sender: tx.Sender,
|
||||
Message: tx.Message,
|
||||
Confidence: string(m.Confidence),
|
||||
})
|
||||
ledger[memberName][mw.month] = md
|
||||
}
|
||||
} else if totalExpected > 0 {
|
||||
// Proportional: distribute by each month's share; last month absorbs float remainder
|
||||
if totalExpected > 0 {
|
||||
// Fill-first: iterate inWindow in matched-months order (chronological by
|
||||
// convention), allocating min(remaining, deficit) to each month. Deficit
|
||||
// is net of what prior transactions already paid, so a second payment on
|
||||
// the same months correctly fills only what remains due. Any surplus after
|
||||
// all deficits are covered goes to the credit bucket.
|
||||
remaining := inWindowShare
|
||||
for i, mw := range inWindow {
|
||||
var alloc float64
|
||||
if i == len(inWindow)-1 {
|
||||
alloc = remaining
|
||||
} else {
|
||||
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
|
||||
}
|
||||
remaining -= alloc
|
||||
for _, mw := range inWindow {
|
||||
md := ledger[memberName][mw.month]
|
||||
deficit := float64(mw.expected) - md.Paid
|
||||
if deficit < 0 {
|
||||
deficit = 0
|
||||
}
|
||||
alloc := remaining
|
||||
if deficit < alloc {
|
||||
alloc = deficit
|
||||
}
|
||||
if alloc <= 0 {
|
||||
continue
|
||||
}
|
||||
md.Paid += alloc
|
||||
md.Transactions = append(md.Transactions, TxEntry{
|
||||
Amount: alloc,
|
||||
@@ -341,6 +352,10 @@ func Reconcile(
|
||||
Confidence: string(m.Confidence),
|
||||
})
|
||||
ledger[memberName][mw.month] = md
|
||||
remaining -= alloc
|
||||
}
|
||||
if remaining > 0 {
|
||||
credits[memberName] += int(remaining)
|
||||
}
|
||||
} else {
|
||||
// Even-split fallback: prepayment before attendance recorded
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package reconcile
|
||||
|
||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
||||
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
|
||||
// PYTHONPATH=legacy/scripts:legacy python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
|
||||
//
|
||||
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
|
||||
|
||||
@@ -29,7 +29,7 @@ func tx(person, purpose string, amount float64) Transaction {
|
||||
|
||||
func TestReconcileExceptionOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
|
||||
exceptions := map[ExceptionKey]Exception{
|
||||
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestReconcileExceptionOverride(t *testing.T) {
|
||||
|
||||
func TestReconcileFallbackToAttendance(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||
|
||||
@@ -68,9 +68,9 @@ func TestReconcileGreedyExactMatch(t *testing.T) {
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{
|
||||
"2026-02": {750, 3},
|
||||
"2026-03": {350, 3},
|
||||
"2026-04": {150, 2},
|
||||
"2026-02": {Expected: 750, Attendance: 3},
|
||||
"2026-03": {Expected: 350, Attendance: 3},
|
||||
"2026-04": {Expected: 150, Attendance: 2},
|
||||
},
|
||||
}}
|
||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||
@@ -93,7 +93,7 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}},
|
||||
Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 750, Attendance: 3}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -111,42 +111,32 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileProportionalUnderpayment(t *testing.T) {
|
||||
func TestReconcileUnderpaymentFillsEarliestFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}},
|
||||
Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||
amount := 1250.0
|
||||
|
||||
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", amount)}, nil, defaultYear)
|
||||
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear)
|
||||
|
||||
months := result.Members["Alice"].Months
|
||||
paid02 := months["2026-02"].Paid
|
||||
paid03 := months["2026-03"].Paid
|
||||
paid04 := months["2026-04"].Paid
|
||||
|
||||
if paid02 >= 750 {
|
||||
t.Errorf("2026-02 should be underpaid, got %f", paid02)
|
||||
// 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
|
||||
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
|
||||
}
|
||||
if paid03 >= 350 {
|
||||
t.Errorf("2026-03 should be underpaid, got %f", paid03)
|
||||
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
|
||||
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
|
||||
}
|
||||
if paid04 >= 750 {
|
||||
t.Errorf("2026-04 should be underpaid, got %f", paid04)
|
||||
}
|
||||
if math.Abs(paid02+paid03+paid04-amount) > 0.01 {
|
||||
t.Errorf("sum of paid want %f, got %f", amount, paid02+paid03+paid04)
|
||||
}
|
||||
if math.Abs(paid02-paid04) > 0.01 {
|
||||
t.Errorf("02 and 04 have equal expected, want equal paid: %f vs %f", paid02, paid04)
|
||||
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
|
||||
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
|
||||
|
||||
@@ -158,8 +148,8 @@ func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{
|
||||
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
||||
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
||||
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
|
||||
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
|
||||
}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -180,7 +170,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}},
|
||||
Fees: map[string]FeeData{"2026-01": {Expected: 0, Attendance: 0}, "2026-02": {Expected: 0, Attendance: 0}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -197,7 +187,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
||||
|
||||
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
|
||||
txFn := func(person string) Transaction {
|
||||
return Transaction{
|
||||
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
|
||||
@@ -232,7 +222,7 @@ func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||
|
||||
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-04-15", Amount: 750,
|
||||
Person: "Někdo Neznámý", Purpose: "2026-04",
|
||||
@@ -252,7 +242,7 @@ func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||
// [Go] Test that [?] markers are stripped from the Person field before lookup.
|
||||
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 750,
|
||||
Person: "[?] Alice", Purpose: "2026-01",
|
||||
@@ -267,9 +257,36 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||
}
|
||||
|
||||
// [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) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 300,
|
||||
Person: "Alice", Purpose: "other:shirt",
|
||||
@@ -297,7 +314,7 @@ func TestReconcileOtherPurpose(t *testing.T) {
|
||||
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 600, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 1200,
|
||||
Person: "Alice", Purpose: "2026-01, 2026-02",
|
||||
@@ -322,7 +339,7 @@ func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||
// [Go] No person/purpose → inference fallback resolves sender name and date month.
|
||||
func TestReconcileInferenceFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}}
|
||||
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-04-15", Amount: 750,
|
||||
// Person and Purpose are empty → inference path
|
||||
@@ -340,7 +357,7 @@ func TestReconcileInferenceFallback(t *testing.T) {
|
||||
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
|
||||
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 500,
|
||||
// empty person+purpose and sender name not matching any member
|
||||
@@ -360,7 +377,7 @@ func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||
// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected.
|
||||
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||
|
||||
@@ -374,3 +391,52 @@ func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
||||
t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched)
|
||||
}
|
||||
}
|
||||
|
||||
// Payment < total expected → fill earliest months first, spill remainder to later.
|
||||
func TestUnderpaymentFillsEarliestFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{
|
||||
"2026-02": {Expected: 750, Attendance: 3},
|
||||
"2026-03": {Expected: 350, Attendance: 3},
|
||||
"2026-04": {Expected: 750, Attendance: 3},
|
||||
}}}
|
||||
txs := []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}
|
||||
|
||||
result := Reconcile(members, []string{"2026-02", "2026-03", "2026-04"}, txs, nil, defaultYear)
|
||||
months := result.Members["Alice"].Months
|
||||
|
||||
// 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
|
||||
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
|
||||
}
|
||||
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
|
||||
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
|
||||
}
|
||||
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
|
||||
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
|
||||
}
|
||||
}
|
||||
|
||||
// Prior txn fills 02 partially; later txn finishes 02 then spills to 03.
|
||||
func TestFillFirstAcrossTwoTransactions(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Matyáš", Tier: "A", Fees: map[string]FeeData{
|
||||
"2026-02": {Expected: 500, Attendance: 2},
|
||||
"2026-03": {Expected: 250, Attendance: 1},
|
||||
}}}
|
||||
sortedMonths := []string{"2026-02", "2026-03"}
|
||||
txs := []Transaction{
|
||||
tx("Matyáš", "2026-02", 200),
|
||||
tx("Matyáš", "2026-02, 2026-03", 550),
|
||||
}
|
||||
|
||||
result := Reconcile(members, sortedMonths, txs, nil, defaultYear)
|
||||
months := result.Members["Matyáš"].Months
|
||||
|
||||
if math.Abs(months["2026-02"].Paid-500) > 0.01 {
|
||||
t.Errorf("02: want 500, got %f", months["2026-02"].Paid)
|
||||
}
|
||||
if math.Abs(months["2026-03"].Paid-250) > 0.01 {
|
||||
t.Errorf("03: want 250, got %f", months["2026-03"].Paid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package synch ports the bank-sync deduplication helper from
|
||||
// scripts/sync_fio_to_sheets.py.
|
||||
// legacy/scripts/sync_fio_to_sheets.py.
|
||||
package synch
|
||||
|
||||
import (
|
||||
@@ -30,7 +30,7 @@ type Transaction struct {
|
||||
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
||||
// as the dedup key in column K of the payments sheet.
|
||||
//
|
||||
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
|
||||
// Byte-stable with legacy/scripts/sync_fio_to_sheets.py generate_sync_id.
|
||||
func GenerateSyncID(tx Transaction) string {
|
||||
currency := tx.Currency
|
||||
if currency == "" {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
// All expected digests verified against the live Python implementation on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||
// from sync_fio_to_sheets import generate_sync_id
|
||||
// cases = [
|
||||
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
||||
@@ -88,7 +88,7 @@ func TestGenerateSyncID(t *testing.T) {
|
||||
|
||||
// All expected strings verified against the live Python implementation on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
|
||||
// print(repr(v), "->", repr(str(v)))
|
||||
// '
|
||||
|
||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
||||
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
|
||||
// mirroring scripts/cache_utils.py.
|
||||
// mirroring legacy/scripts/cache_utils.py.
|
||||
package cache
|
||||
|
||||
import (
|
||||
@@ -26,7 +26,7 @@ type cacheFile struct {
|
||||
|
||||
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
|
||||
//
|
||||
// Two TTL knobs mirror scripts/cache_utils.py:
|
||||
// Two TTL knobs mirror legacy/scripts/cache_utils.py:
|
||||
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
|
||||
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
|
||||
//
|
||||
|
||||
@@ -17,7 +17,7 @@ type httpDoer interface {
|
||||
}
|
||||
|
||||
// apiClient fetches transactions from the Fio REST API (JSON).
|
||||
// Ports scripts/fio_utils.py fetch_transactions_api.
|
||||
// Ports legacy/scripts/fio_utils.py fetch_transactions_api.
|
||||
type apiClient struct {
|
||||
token string
|
||||
hc httpDoer
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
// transparentClient fetches transactions from the Fio transparent account page (HTML).
|
||||
// Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
|
||||
// Ports legacy/scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
|
||||
type transparentClient struct {
|
||||
accountNum string
|
||||
hc httpDoer
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,9 @@ package banksync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/io/fio"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"fuj-management/go/internal/io/fio"
|
||||
)
|
||||
|
||||
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
|
||||
|
||||
@@ -30,7 +30,7 @@ type sheetReadWriter interface {
|
||||
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
|
||||
// sheet using name and month matching against the member list.
|
||||
// Returns the number of rows updated (or that would be updated on dry-run).
|
||||
// Ports scripts/infer_payments.py infer_payments.
|
||||
// Ports legacy/scripts/infer_payments.py infer_payments.
|
||||
func InferPayments(
|
||||
ctx context.Context,
|
||||
spreadsheetID string,
|
||||
@@ -94,7 +94,7 @@ func InferPayments(
|
||||
if idx < 0 || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row[idx]))
|
||||
return strings.TrimSpace(sheets.CellString(row[idx]))
|
||||
}
|
||||
|
||||
// Skip rule: any of manual fix / Person / Purpose non-empty → leave alone
|
||||
|
||||
@@ -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) {
|
||||
sh := &sheets.Fake{Values: map[string][][]any{
|
||||
"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"
|
||||
"fuj-management/go/internal/domain/synch"
|
||||
"fuj-management/go/internal/io/fio"
|
||||
"fuj-management/go/internal/io/sheets"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// columnLabels is the canonical header for the payments sheet.
|
||||
// Mirrors COLUMN_LABELS in scripts/sync_fio_to_sheets.py.
|
||||
// Mirrors COLUMN_LABELS in legacy/scripts/sync_fio_to_sheets.py.
|
||||
var columnLabels = []string{
|
||||
"Date", "Amount", "manual fix", "Person", "Purpose",
|
||||
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
|
||||
@@ -35,13 +36,25 @@ type SyncOpts struct {
|
||||
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
|
||||
}
|
||||
|
||||
// SyncToSheets fetches Fio transactions and appends new ones to the payments sheet.
|
||||
// Returns the number of rows appended.
|
||||
// Ports scripts/sync_fio_to_sheets.py sync_to_sheets.
|
||||
// DefaultWindow is the window used by the dashboard sync: the whole current
|
||||
// calendar year plus the last two months of the previous one, so payments
|
||||
// dated around New Year are still picked up in January. Re-fetching is safe
|
||||
// because rows are deduplicated by Sync ID.
|
||||
// Mirrors default_sync_window in legacy/scripts/sync_fio_to_sheets.py.
|
||||
func DefaultWindow(now time.Time) (from, to time.Time) {
|
||||
yr := now.Year()
|
||||
from = time.Date(yr-1, time.November, 1, 0, 0, 0, 0, time.UTC)
|
||||
to = time.Date(yr, time.December, 31, 23, 59, 59, 0, time.UTC)
|
||||
return from, to
|
||||
}
|
||||
|
||||
// SyncToSheets fetches Fio transactions from all provided clients and appends
|
||||
// new ones to the payments sheet. Returns the number of rows appended.
|
||||
// Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
|
||||
func SyncToSheets(
|
||||
ctx context.Context,
|
||||
spreadsheetID string,
|
||||
fioClient fio.Client,
|
||||
fioClients []fio.Client,
|
||||
sh sheetsWriter,
|
||||
opts SyncOpts,
|
||||
) (int, error) {
|
||||
@@ -84,11 +97,15 @@ func SyncToSheets(
|
||||
from = to.AddDate(0, 0, -days)
|
||||
}
|
||||
|
||||
// 3. Fetch Fio transactions.
|
||||
txns, err := fioClient.FetchTransactions(ctx, from, to)
|
||||
// 3. Fetch Fio transactions from each account and combine.
|
||||
var txns []fio.Transaction
|
||||
for _, client := range fioClients {
|
||||
got, err := client.FetchTransactions(ctx, from, to)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sync: fetch fio: %w", err)
|
||||
}
|
||||
txns = append(txns, got...)
|
||||
}
|
||||
if opts.DryRun {
|
||||
fmt.Printf("Dry run: window %s to %s, fetched %d transaction(s) from Fio\n",
|
||||
from.Format("2006-01-02"), to.Format("2006-01-02"), len(txns))
|
||||
@@ -126,7 +143,7 @@ func SyncToSheets(
|
||||
newRows = append(newRows, []any{
|
||||
tx.Date, tx.Amount,
|
||||
"", "", "", "", // manual fix, Person, Purpose, Inferred Amount
|
||||
tx.Sender, tx.VS, tx.Message, tx.BankID, syncIDs[i],
|
||||
tx.Sender, sheets.TextCell(tx.VS), tx.Message, tx.BankID, syncIDs[i],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,7 +154,7 @@ func SyncToSheets(
|
||||
if opts.DryRun {
|
||||
for _, row := range newRows {
|
||||
fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n",
|
||||
row[0], row[1], row[6], row[7], row[8])
|
||||
row[0], row[1], row[6], strings.TrimPrefix(fmt.Sprint(row[7]), "'"), row[8])
|
||||
}
|
||||
if opts.Sort {
|
||||
fmt.Println("Dry run: would sort by date")
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
|
||||
}}
|
||||
fioFake := &fio.Fake{Transactions: testFioTxns}
|
||||
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -38,6 +38,10 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
|
||||
if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 {
|
||||
t.Errorf("expected 64-char hex sync ID, got %v", rows[0][10])
|
||||
}
|
||||
// VS (index 7) is written as text so Sheets keeps it a string.
|
||||
if vs, _ := rows[0][7].(string); vs != "'123" {
|
||||
t.Errorf("expected VS written as text cell '123, got %v", rows[0][7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToSheets_Dedup(t *testing.T) {
|
||||
@@ -51,7 +55,7 @@ func TestSyncToSheets_Dedup(t *testing.T) {
|
||||
}}
|
||||
fioFake := &fio.Fake{Transactions: testFioTxns}
|
||||
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -72,7 +76,7 @@ func TestSyncToSheets_NoNewTxns(t *testing.T) {
|
||||
}}
|
||||
fioFake := &fio.Fake{Transactions: testFioTxns}
|
||||
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -92,7 +96,7 @@ func TestSyncToSheets_MissingHeader(t *testing.T) {
|
||||
}}
|
||||
fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
|
||||
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,7 +109,7 @@ func TestSyncToSheets_Sort(t *testing.T) {
|
||||
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
|
||||
fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
|
||||
|
||||
_, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30, Sort: true})
|
||||
_, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30, Sort: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -118,7 +122,7 @@ func TestSyncToSheets_ExplicitDateWindow(t *testing.T) {
|
||||
|
||||
from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{From: from, To: to})
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{From: from, To: to})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -131,7 +135,7 @@ func TestSyncToSheets_DryRun(t *testing.T) {
|
||||
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
|
||||
fioFake := &fio.Fake{Transactions: testFioTxns}
|
||||
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh,
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh,
|
||||
SyncOpts{Days: 30, Sort: true, DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -144,6 +148,40 @@ func TestSyncToSheets_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToSheets_MultiAccount(t *testing.T) {
|
||||
txnsA := []fio.Transaction{
|
||||
{Date: "2026-04-10", Amount: 700, Sender: "Alice", Message: "april", VS: "1", BankID: "A1"},
|
||||
}
|
||||
txnsB := []fio.Transaction{
|
||||
{Date: "2026-04-11", Amount: 500, Sender: "Bob", Message: "duben", VS: "2", BankID: "B1"},
|
||||
}
|
||||
// One transaction that duplicates the first one from account A (same sync_id).
|
||||
dupID := syncIDFor(txnsA[0])
|
||||
sh := &sheets.Fake{Values: map[string][][]any{
|
||||
"SHEETID/A1:K": {
|
||||
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
||||
{"2026-04-10", 700.0, "", "", "", "", "Alice", "1", "april", "A1", dupID},
|
||||
},
|
||||
}}
|
||||
fakeA := &fio.Fake{Transactions: txnsA}
|
||||
fakeB := &fio.Fake{Transactions: txnsB}
|
||||
|
||||
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fakeA, fakeB}, sh, SyncOpts{Days: 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("want 1 new row (B1 from account B; A1 is duplicate), got %d", n)
|
||||
}
|
||||
if len(sh.Appended) != 1 || len(sh.Appended[0].Rows) != 1 {
|
||||
t.Fatalf("want exactly 1 row appended, got %v", sh.Appended)
|
||||
}
|
||||
row := sh.Appended[0].Rows[0]
|
||||
if row[6] != "Bob" {
|
||||
t.Errorf("expected Bob's row, got sender=%v", row[6])
|
||||
}
|
||||
}
|
||||
|
||||
// syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction.
|
||||
func syncIDFor(tx fio.Transaction) string {
|
||||
currency := tx.Currency
|
||||
@@ -155,3 +193,23 @@ func syncIDFor(tx fio.Transaction) string {
|
||||
Sender: tx.Sender, VS: tx.VS, Message: tx.Message, BankID: tx.BankID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultWindow(t *testing.T) {
|
||||
cases := []struct {
|
||||
now time.Time
|
||||
from, to string
|
||||
}{
|
||||
{time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC), "2025-11-01", "2026-12-31"},
|
||||
{time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), "2026-11-01", "2027-12-31"},
|
||||
{time.Date(2027, 12, 31, 23, 0, 0, 0, time.UTC), "2026-11-01", "2027-12-31"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
from, to := DefaultWindow(tc.now)
|
||||
if got := from.Format("2006-01-02"); got != tc.from {
|
||||
t.Errorf("DefaultWindow(%s) from: want %s, got %s", tc.now.Format("2006-01-02"), tc.from, got)
|
||||
}
|
||||
if got := to.Format("2006-01-02"); got != tc.to {
|
||||
t.Errorf("DefaultWindow(%s) to: want %s, got %s", tc.now.Format("2006-01-02"), tc.to, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
)
|
||||
|
||||
// printFeesTable writes a fixed-width adult-fees table to w.
|
||||
// Mirrors scripts/calculate_fees.py main().
|
||||
// Mirrors legacy/scripts/calculate_fees.py main().
|
||||
//
|
||||
// Verify with:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
|
||||
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
|
||||
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
|
||||
type row struct {
|
||||
name string
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package membership
|
||||
|
||||
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06:
|
||||
// Golden strings verified against legacy/scripts/calculate_fees.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
|
||||
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
|
||||
//
|
||||
// (feed equivalent fixture data via attendance sheet or local CSV)
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
)
|
||||
|
||||
// printReconcileReport writes the full balance report to w.
|
||||
// Mirrors scripts/match_payments.py print_report().
|
||||
// Mirrors legacy/scripts/match_payments.py print_report().
|
||||
//
|
||||
// Verify with:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
|
||||
// ...'
|
||||
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package membership
|
||||
|
||||
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06:
|
||||
// Golden strings verified against legacy/scripts/match_payments.py print_report() on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||
// from match_payments import print_report
|
||||
// result = {
|
||||
// "members": {
|
||||
|
||||
@@ -25,11 +25,17 @@ type ExceptionLoader interface {
|
||||
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
|
||||
}
|
||||
|
||||
// CacheFlusher can invalidate all cached data.
|
||||
type CacheFlusher interface {
|
||||
FlushCache() (int, error)
|
||||
}
|
||||
|
||||
// Sources is the aggregate interface required by ReconcileReport.
|
||||
type Sources interface {
|
||||
AttendanceLoader
|
||||
TransactionLoader
|
||||
ExceptionLoader
|
||||
CacheFlusher
|
||||
}
|
||||
|
||||
// NewStubSources returns a Sources whose every method returns ErrIOPending.
|
||||
@@ -52,3 +58,5 @@ func (stubSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction,
|
||||
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||
return nil, ErrIOPending
|
||||
}
|
||||
|
||||
func (stubSources) FlushCache() (int, error) { return 0, nil }
|
||||
|
||||
@@ -31,6 +31,8 @@ func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionK
|
||||
return f.exceptions, nil
|
||||
}
|
||||
|
||||
func (fakeSources) FlushCache() (int, error) { return 0, nil }
|
||||
|
||||
func TestReconcileReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := fakeSources{
|
||||
|
||||
@@ -18,19 +18,21 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py)
|
||||
// Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py)
|
||||
const (
|
||||
colName = 0
|
||||
colTier = 1
|
||||
firstDateCol = 3
|
||||
)
|
||||
|
||||
// adultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
||||
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in legacy/scripts/attendance.py.
|
||||
// Source month → target month (source attendance accumulated into target).
|
||||
var adultMergedMonths = map[string]string{}
|
||||
var AdultMergedMonths = map[string]string{
|
||||
"2025-09": "2025-10",
|
||||
}
|
||||
|
||||
// juniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||
var juniorMergedMonths = map[string]string{
|
||||
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in legacy/scripts/attendance.py.
|
||||
var JuniorMergedMonths = map[string]string{
|
||||
"2025-12": "2026-01",
|
||||
"2025-09": "2025-10",
|
||||
}
|
||||
@@ -109,6 +111,9 @@ func (s *realSources) LoadTransactions(ctx context.Context) ([]reconcile.Transac
|
||||
return parseTransactionRows(rows)
|
||||
}
|
||||
|
||||
// FlushCache deletes all cached data files and resets in-memory cache state.
|
||||
func (s *realSources) FlushCache() (int, error) { return s.cache.Flush() }
|
||||
|
||||
// LoadExceptions fetches the exceptions tab (cached).
|
||||
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
|
||||
@@ -122,11 +127,11 @@ func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.Excepti
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attendance CSV parsing (ports scripts/attendance.py)
|
||||
// Attendance CSV parsing (ports legacy/scripts/attendance.py)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
|
||||
// Ports scripts/attendance.py parse_dates + strftime("%Y-%m").
|
||||
// Ports legacy/scripts/attendance.py parse_dates + strftime("%Y-%m").
|
||||
func parseDates(header []string) []struct {
|
||||
col int
|
||||
month string
|
||||
@@ -142,7 +147,13 @@ func parseDates(header []string) []struct {
|
||||
}
|
||||
var dt time.Time
|
||||
var err error
|
||||
for _, fmt_ := range []string{"02.01.2006", "01/02/2006"} {
|
||||
// Use the unpadded reference forms ("2.1" and "1/2"): Go's time.Parse
|
||||
// accepts both single-digit and zero-padded inputs against them, so
|
||||
// "1.6.2026", "01.06.2026", "23.3.2026" all parse. Czech sheet authors
|
||||
// drop the leading zero on dates ≤ 9 — Python's strptime is lenient
|
||||
// the same way; the previous "02.01.2006" form silently dropped those
|
||||
// columns and undercounted attendance.
|
||||
for _, fmt_ := range []string{"2.1.2006", "1/2/2006"} {
|
||||
dt, err = time.Parse(fmt_, raw)
|
||||
if err == nil {
|
||||
break
|
||||
@@ -189,13 +200,13 @@ func countTrue(row []string, cols []int) int {
|
||||
|
||||
// parseAdultRows converts raw CSV rows to []reconcile.Member.
|
||||
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
|
||||
// Ports scripts/attendance.py get_members_with_fees.
|
||||
// Ports legacy/scripts/attendance.py get_members_with_fees.
|
||||
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
||||
if len(rows) < 2 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
dates := parseDates(rows[0])
|
||||
months := groupByMonth(dates, adultMergedMonths)
|
||||
months := groupByMonth(dates, AdultMergedMonths)
|
||||
sortedMonths := sortedKeys(months)
|
||||
|
||||
var members []reconcile.Member
|
||||
@@ -235,7 +246,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
||||
|
||||
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
|
||||
// with the junior sheet, then calling CalculateJuniorFee.
|
||||
// Ports scripts/attendance.py get_junior_members_with_fees.
|
||||
// Ports legacy/scripts/attendance.py get_junior_members_with_fees.
|
||||
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
|
||||
if len(adultRows) < 2 || len(juniorRows) < 2 {
|
||||
return nil, nil, nil
|
||||
@@ -243,8 +254,8 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
||||
|
||||
mainDates := parseDates(adultRows[0])
|
||||
juniorDates := parseDates(juniorRows[0])
|
||||
mainMonths := groupByMonth(mainDates, juniorMergedMonths)
|
||||
jrMonths := groupByMonth(juniorDates, juniorMergedMonths)
|
||||
mainMonths := groupByMonth(mainDates, JuniorMergedMonths)
|
||||
jrMonths := groupByMonth(juniorDates, JuniorMergedMonths)
|
||||
|
||||
allMonths := make(map[string]bool)
|
||||
for m := range mainMonths {
|
||||
@@ -337,7 +348,13 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
||||
if !exp.Unknown {
|
||||
fee = exp.Value
|
||||
}
|
||||
feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: total}
|
||||
feeMap[m] = reconcile.FeeData{
|
||||
Expected: fee,
|
||||
IsUnknown: exp.Unknown,
|
||||
Attendance: total,
|
||||
JuniorAttendance: c.junior,
|
||||
AdultAttendance: c.adult,
|
||||
}
|
||||
}
|
||||
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
|
||||
}
|
||||
@@ -345,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) {
|
||||
@@ -365,11 +382,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
}
|
||||
idxDate := idx("date")
|
||||
idxAmount := idx("amount")
|
||||
idxManualFix := idx("manual fix")
|
||||
idxPerson := idx("person")
|
||||
idxPurpose := idx("purpose")
|
||||
idxInferred := idx("inferred amount")
|
||||
idxSender := idx("sender")
|
||||
idxVS := idx("vs")
|
||||
idxMessage := idx("message")
|
||||
idxBankID := idx("bank id")
|
||||
idxSyncID := idx("sync id")
|
||||
|
||||
for _, label := range []string{"date", "amount", "person", "purpose"} {
|
||||
if idx(label) == -1 {
|
||||
@@ -381,12 +402,22 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
if i < 0 || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(row[i])
|
||||
return sheets.CellString(row[i])
|
||||
}
|
||||
|
||||
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
||||
// dispatch on the underlying numeric type (Sheets returns serial-day
|
||||
// numbers as float64). Stringifying first defeats that dispatch.
|
||||
getRaw := func(row []any, i int) any {
|
||||
if i < 0 || i >= len(row) {
|
||||
return nil
|
||||
}
|
||||
return row[i]
|
||||
}
|
||||
|
||||
var txns []reconcile.Transaction
|
||||
for _, row := range rows[1:] {
|
||||
dateStr := matching.FormatDate(getVal(row, idxDate))
|
||||
dateStr := matching.FormatDate(getRaw(row, idxDate))
|
||||
amountRaw := row[idxAmount]
|
||||
if idxAmount < 0 || idxAmount >= len(row) {
|
||||
amountRaw = ""
|
||||
@@ -394,7 +425,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
amount := parseFloat(amountRaw)
|
||||
|
||||
var inferredAmount *float64
|
||||
if iv := getVal(row, idxInferred); iv != "" && iv != "<nil>" {
|
||||
if iv := getVal(row, idxInferred); iv != "" {
|
||||
if f := parseFloat(iv); f != 0 {
|
||||
inferredAmount = &f
|
||||
}
|
||||
@@ -403,11 +434,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
txns = append(txns, reconcile.Transaction{
|
||||
Date: dateStr,
|
||||
Amount: amount,
|
||||
ManualFix: getVal(row, idxManualFix),
|
||||
Person: getVal(row, idxPerson),
|
||||
Purpose: getVal(row, idxPurpose),
|
||||
InferredAmount: inferredAmount,
|
||||
Sender: getVal(row, idxSender),
|
||||
VS: getVal(row, idxVS),
|
||||
Message: getVal(row, idxMessage),
|
||||
BankID: getVal(row, idxBankID),
|
||||
SyncID: getVal(row, idxSyncID),
|
||||
})
|
||||
}
|
||||
return txns, nil
|
||||
@@ -431,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 {
|
||||
|
||||
@@ -46,8 +46,8 @@ func TestLoadAdults(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// adultMergedMonths is empty so 2025-09 stays as-is
|
||||
if len(months) != 1 || months[0] != "2025-09" {
|
||||
// AdultMergedMonths sends 2025-09 → 2025-10
|
||||
if len(months) != 1 || months[0] != "2025-10" {
|
||||
t.Errorf("unexpected months: %v", months)
|
||||
}
|
||||
if len(members) != 2 {
|
||||
@@ -55,7 +55,7 @@ func TestLoadAdults(t *testing.T) {
|
||||
}
|
||||
byName := map[string]int{}
|
||||
for _, m := range members {
|
||||
byName[m.Name] = m.Fees["2025-09"].Attendance
|
||||
byName[m.Name] = m.Fees["2025-10"].Attendance
|
||||
}
|
||||
if byName["Alice"] != 2 {
|
||||
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
|
||||
@@ -73,9 +73,9 @@ func TestLoadAdults_Fee(t *testing.T) {
|
||||
}
|
||||
byName := map[string]int{}
|
||||
for _, m := range members {
|
||||
byName[m.Name] = m.Fees["2025-09"].Expected
|
||||
byName[m.Name] = m.Fees["2025-10"].Expected
|
||||
}
|
||||
// 2 sessions in 2025-09 → AdultFeeMonthlyRate["2025-09"] = 750
|
||||
// 2 sessions land in merged 2025-10 → AdultFeeMonthlyRate["2025-10"] = 750
|
||||
if byName["Alice"] != 750 {
|
||||
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
|
||||
}
|
||||
@@ -114,12 +114,17 @@ func TestLoadJuniors(t *testing.T) {
|
||||
|
||||
func TestLoadTransactions(t *testing.T) {
|
||||
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
|
||||
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
|
||||
// serial-day form (float64) — the API returns either depending on cell
|
||||
// formatting, and FormatDate must handle both.
|
||||
paymentsKey := config.PaymentsSheetID + "/A1:Z"
|
||||
sh := &sheets.Fake{Values: map[string][][]any{
|
||||
paymentsKey: {
|
||||
{"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-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"},
|
||||
{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)
|
||||
@@ -128,8 +133,8 @@ func TestLoadTransactions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(txns) != 2 {
|
||||
t.Fatalf("want 2 transactions, got %d", len(txns))
|
||||
if len(txns) != 3 {
|
||||
t.Fatalf("want 3 transactions, got %d", len(txns))
|
||||
}
|
||||
if txns[0].Person != "Alice" {
|
||||
t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person)
|
||||
@@ -137,6 +142,18 @@ func TestLoadTransactions(t *testing.T) {
|
||||
if txns[0].Amount != 700 {
|
||||
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
|
||||
}
|
||||
if txns[0].Date != "2026-04-01" {
|
||||
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
|
||||
}
|
||||
if txns[1].Date != "2026-05-05" {
|
||||
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
||||
}
|
||||
if txns[2].VS != "2609767878" {
|
||||
t.Errorf("txn[2].VS (numeric cell): want 2609767878, got %q", txns[2].VS)
|
||||
}
|
||||
if txns[2].BankID != "26123456789" {
|
||||
t.Errorf("txn[2].BankID (numeric cell): want 26123456789, got %q", txns[2].BankID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExceptions(t *testing.T) {
|
||||
@@ -165,6 +182,28 @@ func TestLoadExceptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDates_SingleDigitDayMonth covers the regression where Go's strict
|
||||
// "02.01.2006" format dropped header cells written without leading zeros
|
||||
// (e.g. "1.6.2026", "23.3.2026"), causing attendance undercounts and missing
|
||||
// months on the /api/juniors response. Czech sheet authors drop the zero
|
||||
// pad freely; Python's strptime tolerates it, so the parsers must match.
|
||||
func TestParseDates_SingleDigitDayMonth(t *testing.T) {
|
||||
// Czech form ("DD.MM.YYYY", with leading zeros optional) is the primary
|
||||
// path. The "M/D/YYYY" fallback mirrors Python's %m/%d/%Y secondary
|
||||
// strptime branch — month-first, day-second.
|
||||
header := []string{"Jméno", "Tier", "", "01.06.2026", "1.6.2026", "23.3.2026", "6.4.2026", "01/02/2026", "1/2/2026"}
|
||||
got := parseDates(header)
|
||||
want := []string{"2026-06", "2026-06", "2026-03", "2026-04", "2026-01", "2026-01"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parseDates: got %d entries, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i, e := range got {
|
||||
if e.month != want[i] {
|
||||
t.Errorf("parseDates[%d].month = %q, want %q (raw=%q)", i, e.month, want[i], header[e.col])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TTL smoke test: second call within TTL must not call fetch again.
|
||||
func TestLoadAdults_CacheHit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
// AdultsMonthData is the reconciled ledger for one adult member in one month.
|
||||
// Keys match Python's result["members"][name]["months"][YYYY-MM].
|
||||
type AdultsMonthData struct {
|
||||
Expected int `json:"expected"`
|
||||
OriginalExpected int `json:"original_expected"`
|
||||
AttendanceCount int `json:"attendance_count"`
|
||||
Exception *ExceptionData `json:"exception"`
|
||||
Paid float64 `json:"paid"` // float: proportional allocator may produce fractional CZK
|
||||
Transactions []MemberTxEntry `json:"transactions"`
|
||||
}
|
||||
|
||||
// AdultsMemberData is the reconciled ledger for one adult member.
|
||||
// Keys match Python's result["members"][name].
|
||||
type AdultsMemberData struct {
|
||||
Tier string `json:"tier"`
|
||||
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM → month data
|
||||
OtherTransactions []MemberOtherEntry `json:"other_transactions"`
|
||||
TotalBalance int `json:"total_balance"`
|
||||
}
|
||||
|
||||
// AdultsResponse is the JSON contract for GET /api/adults.
|
||||
// MemberData, MonthLabels, and RawPayments correspond to the Python view-model
|
||||
// fields member_data, month_labels_json, and raw_payments_json respectively,
|
||||
// but as nested objects rather than pre-serialised JSON strings.
|
||||
type AdultsResponse struct {
|
||||
Months []string `json:"months"` // display labels
|
||||
RawMonths []string `json:"raw_months"` // "YYYY-MM"
|
||||
Results []MemberRow `json:"results"`
|
||||
Totals []TotalCell `json:"totals"`
|
||||
MemberData map[string]AdultsMemberData `json:"member_data"` // name → ledger
|
||||
MonthLabels map[string]string `json:"month_labels"` // YYYY-MM → display label
|
||||
RawPayments map[string][]RawTransaction `json:"raw_payments"` // name → raw sheet rows
|
||||
Credits []Credit `json:"credits"`
|
||||
Debts []Credit `json:"debts"`
|
||||
Unmatched []RawTransaction `json:"unmatched"`
|
||||
AttendanceURL string `json:"attendance_url"`
|
||||
PaymentsURL string `json:"payments_url"`
|
||||
BankAccount string `json:"bank_account"`
|
||||
CurrentMonth string `json:"current_month"`
|
||||
MonthsToShow int `json:"months_to_show"`
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
type monthSums struct{ expected, paid int }
|
||||
|
||||
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
||||
// Mirrors legacy/scripts/views.py:build_adults_view_model.
|
||||
func buildAdultsResponse(
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
result domreconcile.Result,
|
||||
txns []domreconcile.Transaction,
|
||||
cfg config.Config,
|
||||
currentMonth string,
|
||||
) AdultsResponse {
|
||||
monthLabels := getMonthLabels(sortedMonths, membership.AdultMergedMonths)
|
||||
|
||||
// Collect tier-A names, sorted.
|
||||
var adultNames []string
|
||||
allNames := make([]string, 0, len(members))
|
||||
for _, m := range members {
|
||||
allNames = append(allNames, m.Name)
|
||||
if m.Tier == "A" {
|
||||
adultNames = append(adultNames, m.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(adultNames)
|
||||
|
||||
// Per-month aggregate totals (expected and paid integers).
|
||||
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
monthlyTotals[m] = &monthSums{}
|
||||
}
|
||||
|
||||
var results []MemberRow
|
||||
for _, name := range adultNames {
|
||||
mr := result.Members[name]
|
||||
row, unpaidMonths, rawUnpaidMonths := buildAdultMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||
row.Balance = settledBalance(mr, currentMonth)
|
||||
row.PayableAmount = max(0, -row.Balance)
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
// Totals row.
|
||||
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||
status := "empty"
|
||||
if t.expected > 0 || t.paid > 0 {
|
||||
switch {
|
||||
case t.paid == t.expected:
|
||||
status = "ok"
|
||||
case t.paid < t.expected:
|
||||
status = "unpaid"
|
||||
default:
|
||||
status = "surplus"
|
||||
}
|
||||
}
|
||||
totalsCells[i] = TotalCell{
|
||||
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
// Credits and debts (settled balance, past months only).
|
||||
var credits, debts []Credit
|
||||
for _, name := range adultNames {
|
||||
bal := settledBalance(result.Members[name], currentMonth)
|
||||
if bal > 0 {
|
||||
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||
} else if bal < 0 {
|
||||
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||
}
|
||||
}
|
||||
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||
|
||||
// member_data: full reconcile output for all members (not just adults).
|
||||
memberData := make(map[string]AdultsMemberData, len(result.Members))
|
||||
for name, mr := range result.Members {
|
||||
months := make(map[string]AdultsMonthData, len(mr.Months))
|
||||
for m, md := range mr.Months {
|
||||
var exc *ExceptionData
|
||||
if md.Exception != nil {
|
||||
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||
}
|
||||
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||
for i, te := range md.Transactions {
|
||||
txEntries[i] = memberTxFromDomain(te)
|
||||
}
|
||||
months[m] = AdultsMonthData{
|
||||
Expected: md.Expected,
|
||||
OriginalExpected: md.OriginalExpected,
|
||||
AttendanceCount: md.AttendanceCount,
|
||||
Exception: exc,
|
||||
Paid: md.Paid,
|
||||
Transactions: txEntries,
|
||||
}
|
||||
}
|
||||
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||
for i, oe := range mr.OtherTransactions {
|
||||
otherTxs[i] = memberOtherFromDomain(oe)
|
||||
}
|
||||
memberData[name] = AdultsMemberData{
|
||||
Tier: mr.Tier,
|
||||
Months: months,
|
||||
OtherTransactions: otherTxs,
|
||||
TotalBalance: mr.TotalBalance,
|
||||
}
|
||||
}
|
||||
|
||||
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||
for i, tx := range result.Unmatched {
|
||||
unmatched[i] = rawTxFromDomain(tx)
|
||||
}
|
||||
|
||||
return AdultsResponse{
|
||||
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||
RawMonths: sortedMonths,
|
||||
Results: ensureSlice(results),
|
||||
Totals: totalsCells,
|
||||
MemberData: memberData,
|
||||
MonthLabels: monthLabels,
|
||||
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||
Credits: ensureSlice(credits),
|
||||
Debts: ensureSlice(debts),
|
||||
Unmatched: unmatched,
|
||||
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
BankAccount: cfg.QRAccount,
|
||||
CurrentMonth: currentMonth,
|
||||
MonthsToShow: cfg.MonthsToShow,
|
||||
}
|
||||
}
|
||||
|
||||
func buildAdultMemberRow(
|
||||
name string,
|
||||
mr domreconcile.MemberResult,
|
||||
sortedMonths []string,
|
||||
monthLabels map[string]string,
|
||||
currentMonth string,
|
||||
monthlyTotals map[string]*monthSums,
|
||||
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||
row = MemberRow{Name: name}
|
||||
for _, m := range sortedMonths {
|
||||
md, ok := mr.Months[m]
|
||||
if !ok {
|
||||
md = domreconcile.MonthData{}
|
||||
}
|
||||
paid := int(md.Paid)
|
||||
expected := md.Expected
|
||||
|
||||
if t := monthlyTotals[m]; t != nil {
|
||||
t.expected += expected
|
||||
t.paid += paid
|
||||
}
|
||||
|
||||
var feeDisplay string
|
||||
var isOverridden bool
|
||||
if md.Exception != nil && md.Exception.Amount != md.OriginalExpected {
|
||||
isOverridden = true
|
||||
if md.AttendanceCount > 0 {
|
||||
feeDisplay = fmt.Sprintf("%d (%d) CZK (%d)", md.Exception.Amount, md.OriginalExpected, md.AttendanceCount)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d (%d) CZK", md.Exception.Amount, md.OriginalExpected)
|
||||
}
|
||||
} else {
|
||||
if md.AttendanceCount > 0 {
|
||||
feeDisplay = fmt.Sprintf("%d CZK (%d)", expected, md.AttendanceCount)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK", expected)
|
||||
}
|
||||
}
|
||||
|
||||
status := "empty"
|
||||
cellText := "-"
|
||||
amountToPay := 0
|
||||
|
||||
switch {
|
||||
case expected > 0:
|
||||
amountToPay = max(0, expected-paid)
|
||||
switch {
|
||||
case paid >= expected:
|
||||
status = "ok"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
case paid > 0:
|
||||
status = "partial"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
default:
|
||||
status = "unpaid"
|
||||
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
}
|
||||
case paid > 0:
|
||||
status = "surplus"
|
||||
cellText = fmt.Sprintf("PAID %d", paid)
|
||||
}
|
||||
|
||||
tooltip := ""
|
||||
if expected > 0 || paid > 0 {
|
||||
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, expected)
|
||||
}
|
||||
|
||||
row.Months = append(row.Months, MonthCell{
|
||||
Text: cellText,
|
||||
Overridden: isOverridden,
|
||||
Status: status,
|
||||
Amount: amountToPay,
|
||||
Month: monthLabels[m],
|
||||
RawMonth: m,
|
||||
Tooltip: tooltip,
|
||||
})
|
||||
}
|
||||
return row, unpaidMonths, rawUnpaidMonths
|
||||
}
|
||||
|
||||
// rawMonthLabel converts "YYYY-MM" to "MM/YYYY" matching Python's strftime("%m/%Y").
|
||||
func rawMonthLabel(m string) string {
|
||||
dt, err := time.Parse("2006-01", m)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
return dt.Format("01/2006")
|
||||
}
|
||||
|
||||
func joinComma(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for _, p := range parts[1:] {
|
||||
result += ", " + p
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinPlus(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for _, p := range parts[1:] {
|
||||
result += "+" + p
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// getMonthLabels builds display labels for sortedMonths, merging month names
|
||||
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
||||
// Mirrors legacy/scripts/views.py:get_month_labels.
|
||||
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
||||
labels := make(map[string]string, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
dt, err := time.Parse("2006-01", m)
|
||||
if err != nil {
|
||||
labels[m] = m
|
||||
continue
|
||||
}
|
||||
var mergedIn []string
|
||||
for src, dst := range mergedMonths {
|
||||
if dst == m {
|
||||
mergedIn = append(mergedIn, src)
|
||||
}
|
||||
}
|
||||
sort.Strings(mergedIn)
|
||||
if len(mergedIn) == 0 {
|
||||
labels[m] = dt.Format("Jan 2006")
|
||||
continue
|
||||
}
|
||||
allMonths := append(mergedIn, m) //nolint:gocritic // intentional: mergedIn already owned
|
||||
sort.Strings(allMonths)
|
||||
years := map[int]bool{}
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
years[d.Year()] = true
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0, len(allMonths))
|
||||
if len(years) > 1 {
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
parts = append(parts, d.Format("Jan 2006"))
|
||||
}
|
||||
}
|
||||
labels[m] = strings.Join(parts, "+")
|
||||
} else {
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
parts = append(parts, d.Format("Jan"))
|
||||
}
|
||||
}
|
||||
labels[m] = strings.Join(parts, "+") + " " + dt.Format("2006")
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// labelsForMonths returns the display labels for sortedMonths in slice order.
|
||||
func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
|
||||
out := make([]string, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
out[i] = labels[m]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||
|
||||
// canonicalKey returns a normalized form of a person name used for deduplication.
|
||||
// Mirrors legacy/scripts/match_payments.py:canonical_member_key.
|
||||
func canonicalKey(name string) string {
|
||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||
}
|
||||
|
||||
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
||||
// canonicalizing names against memberNames where possible.
|
||||
// Mirrors legacy/scripts/views.py:group_payments_by_person (without the
|
||||
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
||||
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
||||
canonicalByKey := make(map[string]string, len(memberNames))
|
||||
for _, n := range memberNames {
|
||||
k := canonicalKey(n)
|
||||
if _, exists := canonicalByKey[k]; !exists {
|
||||
canonicalByKey[k] = n
|
||||
}
|
||||
}
|
||||
grouped := make(map[string][]RawTransaction)
|
||||
for _, tx := range txns {
|
||||
person := strings.TrimSpace(tx.Person)
|
||||
if person == "" {
|
||||
continue
|
||||
}
|
||||
for _, p := range strings.Split(person, ",") {
|
||||
p = questionMarkRe.ReplaceAllString(p, "")
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
key := p
|
||||
if canonical, ok := canonicalByKey[canonicalKey(p)]; ok {
|
||||
key = canonical
|
||||
}
|
||||
grouped[key] = append(grouped[key], rawTxFromDomain(tx))
|
||||
}
|
||||
}
|
||||
for k := range grouped {
|
||||
sort.Slice(grouped[k], func(i, j int) bool {
|
||||
return grouped[k][i].Date > grouped[k][j].Date
|
||||
})
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
// rawTxFromDomain converts a domain Transaction to the wire RawTransaction.
|
||||
func rawTxFromDomain(tx domreconcile.Transaction) RawTransaction {
|
||||
inferredAmount := 0.0
|
||||
if tx.InferredAmount != nil {
|
||||
inferredAmount = *tx.InferredAmount
|
||||
}
|
||||
return RawTransaction{
|
||||
Date: tx.Date,
|
||||
Amount: tx.Amount,
|
||||
ManualFix: tx.ManualFix,
|
||||
Person: tx.Person,
|
||||
Purpose: tx.Purpose,
|
||||
InferredAmount: inferredAmount,
|
||||
Sender: tx.Sender,
|
||||
VS: tx.VS,
|
||||
Message: tx.Message,
|
||||
BankID: tx.BankID,
|
||||
SyncID: tx.SyncID,
|
||||
}
|
||||
}
|
||||
|
||||
// memberTxFromDomain converts a domain TxEntry to a wire MemberTxEntry.
|
||||
func memberTxFromDomain(te domreconcile.TxEntry) MemberTxEntry {
|
||||
return MemberTxEntry{
|
||||
Amount: te.Amount,
|
||||
Date: te.Date,
|
||||
Sender: te.Sender,
|
||||
Message: te.Message,
|
||||
Confidence: te.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// memberOtherFromDomain converts a domain OtherEntry to a wire MemberOtherEntry.
|
||||
func memberOtherFromDomain(oe domreconcile.OtherEntry) MemberOtherEntry {
|
||||
return MemberOtherEntry{
|
||||
Amount: oe.Amount,
|
||||
Date: oe.Date,
|
||||
Sender: oe.Sender,
|
||||
Message: oe.Message,
|
||||
Purpose: oe.Purpose,
|
||||
Confidence: oe.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// settledBalance computes the settled balance: sum of (paid − expected) for months
|
||||
// strictly before currentMonth. Months with IsUnknown=true are excluded to match
|
||||
// Python's isinstance(exp, int) guard (skips "?" months).
|
||||
func settledBalance(mr domreconcile.MemberResult, currentMonth string) int {
|
||||
total := 0
|
||||
for m, md := range mr.Months {
|
||||
if m >= currentMonth || md.IsUnknown {
|
||||
continue
|
||||
}
|
||||
total += int(md.Paid) - md.Expected
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ensureSlice returns s unchanged when non-nil, or an empty (non-nil) slice so
|
||||
// json.Marshal emits [] instead of null.
|
||||
func ensureSlice[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
||||
// Mirrors legacy/scripts/views.py:build_juniors_view_model.
|
||||
func buildJuniorsResponse(
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
result domreconcile.Result,
|
||||
txns []domreconcile.Transaction,
|
||||
cfg config.Config,
|
||||
currentMonth string,
|
||||
) JuniorsResponse {
|
||||
monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths)
|
||||
|
||||
allNames := make([]string, 0, len(members))
|
||||
juniorNames := make([]string, 0, len(members))
|
||||
for _, m := range members {
|
||||
allNames = append(allNames, m.Name)
|
||||
juniorNames = append(juniorNames, m.Name)
|
||||
}
|
||||
sort.Strings(juniorNames)
|
||||
|
||||
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
monthlyTotals[m] = &monthSums{}
|
||||
}
|
||||
|
||||
var results []MemberRow
|
||||
for _, name := range juniorNames {
|
||||
mr := result.Members[name]
|
||||
row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||
row.Balance = settledBalance(mr, currentMonth)
|
||||
row.PayableAmount = max(0, -row.Balance)
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
// Totals row.
|
||||
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||
status := "empty"
|
||||
if t.expected > 0 || t.paid > 0 {
|
||||
switch {
|
||||
case t.paid == t.expected:
|
||||
status = "ok"
|
||||
case t.paid < t.expected:
|
||||
status = "unpaid"
|
||||
default:
|
||||
status = "surplus"
|
||||
}
|
||||
}
|
||||
totalsCells[i] = TotalCell{
|
||||
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
var credits, debts []Credit
|
||||
for _, name := range juniorNames {
|
||||
bal := settledBalance(result.Members[name], currentMonth)
|
||||
if bal > 0 {
|
||||
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||
} else if bal < 0 {
|
||||
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||
}
|
||||
}
|
||||
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||
|
||||
// member_data: full reconcile output for all junior members.
|
||||
memberData := make(map[string]JuniorsMemberData, len(result.Members))
|
||||
for name, mr := range result.Members {
|
||||
months := make(map[string]JuniorsMonthData, len(mr.Months))
|
||||
for m, md := range mr.Months {
|
||||
var exc *ExceptionData
|
||||
if md.Exception != nil {
|
||||
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||
}
|
||||
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||
for i, te := range md.Transactions {
|
||||
txEntries[i] = memberTxFromDomain(te)
|
||||
}
|
||||
months[m] = JuniorsMonthData{
|
||||
Expected: juniorExpected(md),
|
||||
OriginalExpected: juniorOriginalExpected(md),
|
||||
AttendanceCount: md.AttendanceCount,
|
||||
Exception: exc,
|
||||
Paid: md.Paid,
|
||||
Transactions: txEntries,
|
||||
}
|
||||
}
|
||||
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||
for i, oe := range mr.OtherTransactions {
|
||||
otherTxs[i] = memberOtherFromDomain(oe)
|
||||
}
|
||||
memberData[name] = JuniorsMemberData{
|
||||
Tier: mr.Tier,
|
||||
Months: months,
|
||||
OtherTransactions: otherTxs,
|
||||
TotalBalance: mr.TotalBalance,
|
||||
}
|
||||
}
|
||||
|
||||
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||
for i, tx := range result.Unmatched {
|
||||
unmatched[i] = rawTxFromDomain(tx)
|
||||
}
|
||||
|
||||
juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID +
|
||||
"/edit#gid=" + config.JuniorSheetGID
|
||||
|
||||
return JuniorsResponse{
|
||||
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||
RawMonths: sortedMonths,
|
||||
Results: ensureSlice(results),
|
||||
Totals: totalsCells,
|
||||
MemberData: memberData,
|
||||
MonthLabels: monthLabels,
|
||||
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||
Credits: ensureSlice(credits),
|
||||
Debts: ensureSlice(debts),
|
||||
Unmatched: unmatched,
|
||||
AttendanceURL: juniorURL,
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
BankAccount: cfg.QRAccount,
|
||||
CurrentMonth: currentMonth,
|
||||
MonthsToShow: cfg.MonthsToShow,
|
||||
}
|
||||
}
|
||||
|
||||
func buildJuniorMemberRow(
|
||||
name string,
|
||||
mr domreconcile.MemberResult,
|
||||
sortedMonths []string,
|
||||
monthLabels map[string]string,
|
||||
currentMonth string,
|
||||
monthlyTotals map[string]*monthSums,
|
||||
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||
row = MemberRow{Name: name}
|
||||
for _, m := range sortedMonths {
|
||||
md, ok := mr.Months[m]
|
||||
if !ok {
|
||||
md = domreconcile.MonthData{}
|
||||
}
|
||||
paid := int(md.Paid)
|
||||
|
||||
// Update monthly totals (skip "?" months for expected).
|
||||
if t := monthlyTotals[m]; t != nil {
|
||||
if !md.IsUnknown {
|
||||
t.expected += md.Expected
|
||||
}
|
||||
t.paid += paid
|
||||
}
|
||||
|
||||
// Attendance breakdown string e.g. ":3J,2A".
|
||||
var breakdown string
|
||||
jc, ac := md.JuniorAttendance, md.AdultAttendance
|
||||
switch {
|
||||
case jc > 0 && ac > 0:
|
||||
breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac)
|
||||
case jc > 0:
|
||||
breakdown = fmt.Sprintf(":%dJ", jc)
|
||||
case ac > 0:
|
||||
breakdown = fmt.Sprintf(":%dA", ac)
|
||||
}
|
||||
countStr := ""
|
||||
if md.AttendanceCount > 0 {
|
||||
countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown)
|
||||
}
|
||||
|
||||
// Fee display string.
|
||||
var feeDisplay string
|
||||
var isOverridden bool
|
||||
if md.Exception != nil {
|
||||
overrideAmount := md.Exception.Amount
|
||||
var origStr string
|
||||
if md.IsUnknown {
|
||||
origStr = "?"
|
||||
isOverridden = true
|
||||
} else {
|
||||
origStr = strconv.Itoa(md.OriginalExpected)
|
||||
isOverridden = overrideAmount != md.OriginalExpected
|
||||
}
|
||||
if isOverridden {
|
||||
feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||
}
|
||||
} else {
|
||||
if md.IsUnknown {
|
||||
feeDisplay = "? CZK" + countStr
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||
}
|
||||
}
|
||||
|
||||
status := "empty"
|
||||
cellText := "-"
|
||||
amountToPay := 0
|
||||
|
||||
switch {
|
||||
case md.IsUnknown:
|
||||
cellText = "?" + countStr
|
||||
case md.Expected > 0:
|
||||
switch {
|
||||
case paid >= md.Expected:
|
||||
status = "ok"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
case paid > 0:
|
||||
status = "partial"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
amountToPay = md.Expected - paid
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
default:
|
||||
status = "unpaid"
|
||||
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||
amountToPay = md.Expected
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
}
|
||||
case paid > 0:
|
||||
status = "surplus"
|
||||
cellText = fmt.Sprintf("PAID %d", paid)
|
||||
}
|
||||
|
||||
tooltip := ""
|
||||
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
|
||||
// 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{
|
||||
Text: cellText,
|
||||
Overridden: isOverridden,
|
||||
Status: status,
|
||||
Amount: amountToPay,
|
||||
Month: monthLabels[m],
|
||||
RawMonth: m,
|
||||
Tooltip: tooltip,
|
||||
})
|
||||
}
|
||||
return row, unpaidMonths, rawUnpaidMonths
|
||||
}
|
||||
|
||||
// juniorExpected converts domain MonthData to the Expected wire type.
|
||||
// When an exception exists it always produces a concrete int; otherwise
|
||||
// the "?" sentinel is used when IsUnknown=true.
|
||||
func juniorExpected(md domreconcile.MonthData) Expected {
|
||||
if md.Exception == nil && md.IsUnknown {
|
||||
return Expected{Unknown: true}
|
||||
}
|
||||
return Expected{Value: md.Expected}
|
||||
}
|
||||
|
||||
// juniorOriginalExpected converts the original (pre-exception) expected fee.
|
||||
func juniorOriginalExpected(md domreconcile.MonthData) Expected {
|
||||
if md.IsUnknown {
|
||||
return Expected{Unknown: true}
|
||||
}
|
||||
return Expected{Value: md.OriginalExpected}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/config"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
||||
// Mirrors legacy/scripts/views.py:build_payments_view_model.
|
||||
func buildPaymentsResponse(
|
||||
txns []domreconcile.Transaction,
|
||||
memberNames []string,
|
||||
) PaymentsResponse {
|
||||
grouped := groupRawPaymentsByPerson(txns, memberNames)
|
||||
|
||||
// Add unmatched/unknown bucket for transactions with no person set.
|
||||
const unknownKey = "Unmatched / Unknown"
|
||||
for _, tx := range txns {
|
||||
if strings.TrimSpace(tx.Person) == "" {
|
||||
grouped[unknownKey] = append(grouped[unknownKey], rawTxFromDomain(tx))
|
||||
}
|
||||
}
|
||||
// Sort the unknown bucket newest-first (others are sorted in groupRawPaymentsByPerson).
|
||||
if rows, ok := grouped[unknownKey]; ok {
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Date > rows[j].Date })
|
||||
grouped[unknownKey] = rows
|
||||
}
|
||||
|
||||
sortedPeople := make([]string, 0, len(grouped))
|
||||
for p := range grouped {
|
||||
sortedPeople = append(sortedPeople, p)
|
||||
}
|
||||
sort.Strings(sortedPeople)
|
||||
|
||||
return PaymentsResponse{
|
||||
GroupedPayments: grouped,
|
||||
SortedPeople: sortedPeople,
|
||||
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// Handler holds the shared dependencies for all /api/* routes.
|
||||
type Handler struct {
|
||||
BuildVersion string
|
||||
BuildCommit string
|
||||
BuildDate string
|
||||
Sources membership.Sources
|
||||
Config config.Config
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// FlushCache invalidates all cached data via the underlying Sources.
|
||||
func (h *Handler) FlushCache() (int, error) { return h.Sources.FlushCache() }
|
||||
|
||||
// ServeVersion handles GET /api/version.
|
||||
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, VersionResponse{
|
||||
Tag: h.BuildVersion,
|
||||
Commit: h.BuildCommit,
|
||||
BuildDate: h.BuildDate,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeAdults handles GET /api/adults.
|
||||
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||
resp, err := h.AssembleAdults(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// AssembleAdults loads all data and builds the adults view model.
|
||||
// Shared between the JSON API route and the HTML handler.
|
||||
func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
|
||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
||||
if err != nil {
|
||||
return AdultsResponse{}, err
|
||||
}
|
||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||
return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
|
||||
}
|
||||
|
||||
// ServeJuniors handles GET /api/juniors.
|
||||
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||
resp, err := h.AssembleJuniors(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// AssembleJuniors loads all data and builds the juniors view model.
|
||||
// Shared between the JSON API route and the HTML handler.
|
||||
func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) {
|
||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
|
||||
if err != nil {
|
||||
return JuniorsResponse{}, err
|
||||
}
|
||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||
return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
|
||||
}
|
||||
|
||||
// ServePayments handles GET /api/payments.
|
||||
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||
resp, err := h.AssemblePayments(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// AssemblePayments loads transactions and builds the payments view model.
|
||||
// Shared between the JSON API route and the HTML handler.
|
||||
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
|
||||
txns, err := h.Sources.LoadTransactions(ctx)
|
||||
if err != nil {
|
||||
return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err)
|
||||
}
|
||||
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
|
||||
}
|
||||
|
||||
func (h *Handler) loadAll(ctx context.Context, adults bool) (
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
txns []domreconcile.Transaction,
|
||||
exceptions map[domreconcile.ExceptionKey]domreconcile.Exception,
|
||||
err error,
|
||||
) {
|
||||
if adults {
|
||||
members, sortedMonths, err = h.Sources.LoadAdults(ctx)
|
||||
} else {
|
||||
members, sortedMonths, err = h.Sources.LoadJuniors(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load members: %w", err)
|
||||
return
|
||||
}
|
||||
txns, err = h.Sources.LoadTransactions(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load transactions: %w", err)
|
||||
return
|
||||
}
|
||||
exceptions, err = h.Sources.LoadExceptions(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load exceptions: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (h *Handler) allMemberNames(ctx context.Context) []string {
|
||||
var names []string
|
||||
if adults, _, err := h.Sources.LoadAdults(ctx); err == nil {
|
||||
for _, m := range adults {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
if juniors, _, err := h.Sources.LoadJuniors(ctx); err == nil {
|
||||
for _, m := range juniors {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if h.Logger != nil {
|
||||
h.Logger.Error("api error", "path", r.URL.Path, "err", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
// JuniorsMonthData is the reconciled ledger for one junior member in one month.
|
||||
// expected and original_expected may be the "?" sentinel (single-attendance month
|
||||
// requiring manual review); they are carried via the Expected type.
|
||||
type JuniorsMonthData struct {
|
||||
Expected Expected `json:"expected"`
|
||||
OriginalExpected Expected `json:"original_expected"`
|
||||
AttendanceCount int `json:"attendance_count"`
|
||||
Exception *ExceptionData `json:"exception"`
|
||||
Paid float64 `json:"paid"`
|
||||
Transactions []MemberTxEntry `json:"transactions"`
|
||||
}
|
||||
|
||||
// JuniorsMemberData is the reconciled ledger for one junior member.
|
||||
type JuniorsMemberData struct {
|
||||
Tier string `json:"tier"`
|
||||
Months map[string]JuniorsMonthData `json:"months"`
|
||||
OtherTransactions []MemberOtherEntry `json:"other_transactions"`
|
||||
TotalBalance int `json:"total_balance"`
|
||||
}
|
||||
|
||||
// JuniorsResponse is the JSON contract for GET /api/juniors.
|
||||
// Same outer shape as AdultsResponse; differs in that member_data carries
|
||||
// Expected (int or "?") for expected/original_expected fields.
|
||||
type JuniorsResponse struct {
|
||||
Months []string `json:"months"`
|
||||
RawMonths []string `json:"raw_months"`
|
||||
Results []MemberRow `json:"results"`
|
||||
Totals []TotalCell `json:"totals"`
|
||||
MemberData map[string]JuniorsMemberData `json:"member_data"`
|
||||
MonthLabels map[string]string `json:"month_labels"`
|
||||
RawPayments map[string][]RawTransaction `json:"raw_payments"`
|
||||
Credits []Credit `json:"credits"`
|
||||
Debts []Credit `json:"debts"`
|
||||
Unmatched []RawTransaction `json:"unmatched"`
|
||||
AttendanceURL string `json:"attendance_url"`
|
||||
PaymentsURL string `json:"payments_url"`
|
||||
BankAccount string `json:"bank_account"`
|
||||
CurrentMonth string `json:"current_month"`
|
||||
MonthsToShow int `json:"months_to_show"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package api
|
||||
|
||||
// PaymentsResponse is the JSON contract for GET /api/payments.
|
||||
type PaymentsResponse struct {
|
||||
GroupedPayments map[string][]RawTransaction `json:"grouped_payments"` // person name → rows
|
||||
SortedPeople []string `json:"sorted_people"`
|
||||
AttendanceURL string `json:"attendance_url"`
|
||||
PaymentsURL string `json:"payments_url"`
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
// schemagen_test.go generates and golden-compares JSON Schema files for every
|
||||
// /api/X response type.
|
||||
//
|
||||
// Normal run (CI): go test ./internal/web/api/... — asserts schemas match committed files.
|
||||
// Regenerate: go test -run TestGenerateSchemas -update ./internal/web/api/...
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/invopop/jsonschema"
|
||||
)
|
||||
|
||||
var updateFlag = flag.Bool("update", false, "overwrite api-schema fixture files with freshly generated schemas")
|
||||
|
||||
// JSONSchema makes Expected self-describing for the reflector at test time.
|
||||
// The method is in a test file and is not compiled into production binaries.
|
||||
// It emits oneOf [integer, "?"] to match the custom MarshalJSON behaviour.
|
||||
func (Expected) JSONSchema() *jsonschema.Schema {
|
||||
return &jsonschema.Schema{
|
||||
OneOf: []*jsonschema.Schema{
|
||||
{Type: "integer"},
|
||||
{Enum: []any{"?"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSchemas(t *testing.T) {
|
||||
r := &jsonschema.Reflector{
|
||||
AllowAdditionalProperties: false,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
val any
|
||||
}{
|
||||
{"adults", &AdultsResponse{}},
|
||||
{"juniors", &JuniorsResponse{}},
|
||||
{"payments", &PaymentsResponse{}},
|
||||
{"version", &VersionResponse{}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
schema := r.Reflect(tc.val)
|
||||
|
||||
got, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal schema: %v", err)
|
||||
}
|
||||
got = append(got, '\n')
|
||||
|
||||
// Path: go/internal/web/api/ → ../../.. → go/ → tests/fixtures/api-schema/
|
||||
path := filepath.Join("..", "..", "..", "tests", "fixtures", "api-schema", tc.name+".schema.json")
|
||||
|
||||
if *updateFlag {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, got, 0o644); err != nil {
|
||||
t.Fatalf("write schema: %v", err)
|
||||
}
|
||||
t.Logf("wrote %s", path)
|
||||
return
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v (re-run with -update to generate)", path, err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("schema mismatch for %s; re-run with -update to regenerate", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package api defines wire types for the JSON API contract (/api/...).
|
||||
// These structs have explicit json: tags matching the Python view-model dict
|
||||
// keys so that M5 parity tests can do byte-equal comparison between backends.
|
||||
//
|
||||
// The three Python template-only JSON-string fields (member_data,
|
||||
// month_labels_json, raw_payments_json) are represented here as nested objects;
|
||||
// the Python /api/X shadow endpoint strips the json.dumps wrappers before
|
||||
// serialising.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Expected holds a junior fee expectation: either a concrete integer or the
|
||||
// "?" sentinel (single-attendance month requiring manual review).
|
||||
// MarshalJSON emits the integer or the JSON string "?".
|
||||
type Expected struct {
|
||||
Value int
|
||||
Unknown bool
|
||||
}
|
||||
|
||||
func (e Expected) MarshalJSON() ([]byte, error) {
|
||||
if e.Unknown {
|
||||
return []byte(`"?"`), nil
|
||||
}
|
||||
return json.Marshal(e.Value)
|
||||
}
|
||||
|
||||
func (e *Expected) UnmarshalJSON(data []byte) error {
|
||||
if len(data) > 0 && data[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "?" {
|
||||
e.Unknown = true
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("api.Expected: unexpected string %q", s)
|
||||
}
|
||||
e.Unknown = false
|
||||
return json.Unmarshal(data, &e.Value)
|
||||
}
|
||||
|
||||
// ExceptionData is a manual fee override for one member in one month.
|
||||
type ExceptionData struct {
|
||||
Amount int `json:"amount"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// MemberTxEntry is one payment allocation to a member+month, as stored in
|
||||
// member_data.months[YYYY-MM].transactions.
|
||||
type MemberTxEntry struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Date string `json:"date"`
|
||||
Sender string `json:"sender"`
|
||||
Message string `json:"message"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
// MemberOtherEntry is an "other:…" purpose payment allocated to a member.
|
||||
type MemberOtherEntry struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Date string `json:"date"`
|
||||
Sender string `json:"sender"`
|
||||
Message string `json:"message"`
|
||||
Purpose string `json:"purpose"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
// RawTransaction is a full payments-sheet row.
|
||||
// Used for unmatched transactions and raw_payments groupings.
|
||||
// Columns match the sheet layout: Date|Amount|manual fix|Person|Purpose|
|
||||
// Inferred Amount|Sender|VS|Message|Bank ID|Sync ID.
|
||||
type RawTransaction struct {
|
||||
Date string `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
ManualFix string `json:"manual_fix"`
|
||||
Person string `json:"person"`
|
||||
Purpose string `json:"purpose"`
|
||||
InferredAmount float64 `json:"inferred_amount"`
|
||||
Sender string `json:"sender"`
|
||||
VS string `json:"vs"`
|
||||
Message string `json:"message"`
|
||||
BankID string `json:"bank_id"`
|
||||
SyncID string `json:"sync_id"`
|
||||
}
|
||||
|
||||
// MonthCell is one cell in a member's month column on the dashboard.
|
||||
type MonthCell struct {
|
||||
Text string `json:"text"`
|
||||
Overridden bool `json:"overridden"`
|
||||
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
|
||||
Amount int `json:"amount"`
|
||||
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
|
||||
RawMonth string `json:"raw_month"` // "YYYY-MM"
|
||||
Tooltip string `json:"tooltip"`
|
||||
}
|
||||
|
||||
// TotalCell is one cell in the monthly totals row.
|
||||
type TotalCell struct {
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// MemberRow is one member's summary row in the dashboard results table.
|
||||
type MemberRow struct {
|
||||
Name string `json:"name"`
|
||||
Months []MonthCell `json:"months"`
|
||||
Balance int `json:"balance"`
|
||||
UnpaidPeriods string `json:"unpaid_periods"`
|
||||
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
|
||||
PayableAmount int `json:"payable_amount"`
|
||||
}
|
||||
|
||||
// Credit is one entry in the credits or debts lists.
|
||||
type Credit struct {
|
||||
Name string `json:"name"`
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package api
|
||||
|
||||
// VersionResponse is the JSON contract for GET /api/version.
|
||||
// Keys match Python's BUILD_META dict (see legacy/app.py).
|
||||
type VersionResponse struct {
|
||||
Tag string `json:"tag"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEmbedCompleteness guards against a new template or static file being
|
||||
// added to disk but missing from the embedded FS (e.g. a new directory that
|
||||
// the //go:embed glob does not match).
|
||||
func TestEmbedCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
diskDir string
|
||||
embedFS fs.FS
|
||||
embedRoot string
|
||||
}{
|
||||
{"templates", "templates", templateFS, "templates"},
|
||||
{"static", "static", staticFS, "static"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
diskFS := os.DirFS(tc.diskDir)
|
||||
_ = fs.WalkDir(diskFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
embPath := tc.embedRoot + "/" + path
|
||||
if _, statErr := fs.Stat(tc.embedFS, embPath); statErr != nil {
|
||||
t.Errorf("file %q exists on disk but is missing from embed.FS (%v)", embPath, statErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticAssetsServed verifies that /static/* is served from the embedded
|
||||
// FS through the same mux wiring used in server.go, so a standalone binary
|
||||
// with no adjacent static/ directory still delivers assets.
|
||||
func TestStaticAssetsServed(t *testing.T) {
|
||||
subFS, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
t.Fatalf("fs.Sub static: %v", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(subFS)))
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
wantCT string
|
||||
wantSnippet string
|
||||
}{
|
||||
{"/static/css/app.css", "text/css", "body {"},
|
||||
{"/static/js/member-detail.js", "javascript", "Member-detail modal"},
|
||||
{"/static/js/filters.js", "javascript", ""},
|
||||
{"/static/js/payment-qr.js", "javascript", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s: status %d, want 200", tc.path, w.Code)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, tc.wantCT) {
|
||||
t.Errorf("GET %s: Content-Type %q, want it to contain %q", tc.path, ct, tc.wantCT)
|
||||
}
|
||||
if tc.wantSnippet != "" && !strings.Contains(w.Body.String(), tc.wantSnippet) {
|
||||
t.Errorf("GET %s: body missing expected snippet %q", tc.path, tc.wantSnippet)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sanity: unknown path → 404 (file server doesn't fall through silently)
|
||||
t.Run("missing-file", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/static/css/nonexistent.css", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown static path: status %d, want 404", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// HTMLHandler serves the Go-native HTML frontend.
|
||||
type HTMLHandler struct {
|
||||
renderer *Renderer
|
||||
build BuildInfo
|
||||
apiHandler *api.Handler
|
||||
actions ActionHandlers
|
||||
}
|
||||
|
||||
// NewHTMLHandler constructs an HTMLHandler.
|
||||
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHandlers) *HTMLHandler {
|
||||
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) {
|
||||
data, err := h.apiHandler.AssembleAdults(r.Context())
|
||||
if err != nil {
|
||||
h.renderer.Render(w, "adults", AdultsPageData{
|
||||
PageData: h.page("adults"),
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
h.renderer.Render(w, "adults", AdultsPageData{
|
||||
PageData: h.page("adults"),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.apiHandler.AssembleJuniors(r.Context())
|
||||
if err != nil {
|
||||
h.renderer.Render(w, "juniors", JuniorsPageData{
|
||||
PageData: h.page("juniors"),
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
h.renderer.Render(w, "juniors", JuniorsPageData{
|
||||
PageData: h.page("juniors"),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.apiHandler.AssemblePayments(r.Context())
|
||||
if err != nil {
|
||||
h.renderer.Render(w, "payments", PaymentsPageData{
|
||||
PageData: h.page("payments"),
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
h.renderer.Render(w, "payments", PaymentsPageData{
|
||||
PageData: h.page("payments"),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
|
||||
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||
pd := h.page("sync")
|
||||
|
||||
if h.actions.BankSync == nil {
|
||||
h.renderer.Render(w, "sync", SyncPageData{
|
||||
PageData: pd,
|
||||
Output: "Bank sync is not configured.",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
success := true
|
||||
if err := h.actions.BankSync(r.Context(), &buf); err != nil {
|
||||
fmt.Fprintf(&buf, "\nError: %s\n\nStack trace:\n%s", err.Error(), debug.Stack())
|
||||
success = false
|
||||
}
|
||||
|
||||
fmt.Fprintln(&buf, "\n=== Flush Cache ===")
|
||||
n, err := h.apiHandler.FlushCache()
|
||||
if err != nil {
|
||||
fmt.Fprintf(&buf, "flush error: %s\n", err.Error())
|
||||
success = false
|
||||
} else {
|
||||
fmt.Fprintf(&buf, "%d cache file(s) deleted.\n", n)
|
||||
}
|
||||
|
||||
h.renderer.Render(w, "sync", SyncPageData{
|
||||
PageData: h.page("sync"), // rebuilt so the footer reflects this run
|
||||
Output: buf.String(),
|
||||
Success: success,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
|
||||
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||
PageData: h.page("flush"),
|
||||
})
|
||||
}
|
||||
|
||||
// ServeFlushCachePOST handles POST /flush-cache: flushes and re-renders with count.
|
||||
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
|
||||
n, _ := h.apiHandler.FlushCache()
|
||||
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||
PageData: h.page("flush"),
|
||||
Flushed: true,
|
||||
Deleted: n,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeQR handles GET /qr: generates and returns a Czech QR Platba PNG.
|
||||
func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
account := q.Get("account")
|
||||
amount := q.Get("amount")
|
||||
message := q.Get("message")
|
||||
if account == "" {
|
||||
account = h.apiHandler.Config.QRAccount
|
||||
}
|
||||
if amount == "" {
|
||||
amount = "0"
|
||||
}
|
||||
|
||||
payload := BuildSPD(account, amount, message, h.apiHandler.Config.QRAccount)
|
||||
png, err := RenderQRCode(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(png)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/domain/reconcile"
|
||||
"fuj-management/go/internal/web"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fixtureSources returns one adult ("Test Member", tier A) with a 2026-01 fee
|
||||
// of 750 CZK (4 sessions) and a matching payment of 750 — mirrors Python's
|
||||
// test_adults_route fixture.
|
||||
type fixtureSources struct{}
|
||||
|
||||
func (fixtureSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
|
||||
return []reconcile.Member{
|
||||
{Name: "Test Member", Tier: "A", Fees: map[string]reconcile.FeeData{
|
||||
"2026-01": {Expected: 750, Attendance: 4},
|
||||
}},
|
||||
}, []string{"2026-01"}, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
|
||||
amt := float64(750)
|
||||
return []reconcile.Transaction{
|
||||
{Date: "2026-01-01", Amount: 750, Person: "Test Member", Purpose: "2026-01", InferredAmount: &amt},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) FlushCache() (int, error) { return 0, nil }
|
||||
|
||||
func fixtureHandler(t *testing.T) *api.Handler {
|
||||
t.Helper()
|
||||
return &api.Handler{
|
||||
Sources: fixtureSources{},
|
||||
Config: config.Config{QRAccount: "CZ0000000000000000000000"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLHandlerSmoke(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"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{"/adults", h.ServeAdults},
|
||||
{"/juniors", h.ServeJuniors},
|
||||
{"/payments", h.ServePayments},
|
||||
{"/sync-bank", h.ServeSync},
|
||||
{"/flush-cache", h.ServeFlushCacheGET},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
tc.handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if n := strings.Count(body, `class="active"`); n != 1 {
|
||||
t.Errorf(`class="active" count = %d, want 1`, n)
|
||||
}
|
||||
want := fmt.Sprintf(`href="%s" class="active"`, tc.path)
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing active link %q in body", want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdultsPage(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"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeAdults(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Adults Dashboard",
|
||||
"Test Member",
|
||||
"750/750 CZK (4)", // paid/expected (attendance)
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Python assertion: cell text never says literally "OK"
|
||||
if strings.Contains(body, ">OK<") {
|
||||
t.Error("body should not contain >OK<")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModalMarkup(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"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
handler http.HandlerFunc
|
||||
page string
|
||||
wantRow string // data-name present only when the page has rows
|
||||
}{
|
||||
{"/adults", h.ServeAdults, "adults", `data-name="Test Member"`},
|
||||
{"/juniors", h.ServeJuniors, "juniors", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
tc.handler(w, req)
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
`data-page="` + tc.page + `"`,
|
||||
`id="memberModal"`,
|
||||
`/static/js/member-detail.js`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
if tc.wantRow != "" && !strings.Contains(body, tc.wantRow) {
|
||||
t.Errorf("body missing info-icon row %q", tc.wantRow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentsPage(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"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServePayments(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Payments Ledger",
|
||||
"<h2>Test Member</h2>",
|
||||
"750 CZK",
|
||||
"2026-01-01",
|
||||
"2026-01",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeQR(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"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/qr?account=2702008874%2F2010&amount=700&message=Test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeQR(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
|
||||
t.Errorf("Content-Type = %q, want image/png", ct)
|
||||
}
|
||||
// PNG magic bytes: \x89PNG\r\n\x1a\n
|
||||
magic := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
body := w.Body.Bytes()
|
||||
if len(body) < len(magic) || !bytes.Equal(body[:len(magic)], magic) {
|
||||
t.Error("response body does not start with PNG magic bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFlushCacheGET(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"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/flush-cache", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeFlushCacheGET(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Flush Cache") {
|
||||
t.Error("body missing Flush Cache heading")
|
||||
}
|
||||
if strings.Contains(body, "file(s) deleted") {
|
||||
t.Error("GET should not show deleted count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFlushCachePOST(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"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/flush-cache", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeFlushCachePOST(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
// fixtureSources.FlushCache returns 0
|
||||
if !strings.Contains(body, "file(s) deleted") {
|
||||
t.Error("POST should show deleted count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSync(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"}
|
||||
|
||||
actions := web.ActionHandlers{
|
||||
BankSync: func(_ context.Context, out io.Writer) error {
|
||||
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||
fmt.Fprintln(out, "Synced 3 new transaction(s).")
|
||||
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||
fmt.Fprintln(out, "Inferred 2 row(s).")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/sync-bank", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeSync(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"Sync Bank Data",
|
||||
"Synced 3 new transaction(s).",
|
||||
"Inferred 2 row(s).",
|
||||
"Flush Cache",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
renderer, err := web.NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{"/adults", h.ServeAdults},
|
||||
{"/juniors", h.ServeJuniors},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
tc.handler(w, req)
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
`id="qrModal"`,
|
||||
`id="qrImg"`,
|
||||
`id="qrTitle"`,
|
||||
`id="qrAccount"`,
|
||||
`id="qrAmount"`,
|
||||
`id="qrMessage"`,
|
||||
`/static/js/payment-qr.js`,
|
||||
`data-bank-account="CZ0000000000000000000000"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Pay buttons must use <button>, never a bare href to /qr.
|
||||
if strings.Contains(body, `href="/qr`) {
|
||||
t.Error("body must not contain href=/qr (Pay buttons should be <button>, not <a>)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeVersion(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
fixtureHandler(t).ServeVersion(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
for _, key := range []string{"tag", "commit", "build_date"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("JSON response missing key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
var validAccount = regexp.MustCompile(`^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`)
|
||||
|
||||
// BuildSPD builds a Czech QR Platba SPD string, matching Python's qr_code handler.
|
||||
// Invalid account falls back to defaultAccount.
|
||||
// Amount is clamped to [0, 10_000_000]; non-numeric input becomes "0.00".
|
||||
// Message is truncated to 60 runes and stripped of '*' characters.
|
||||
func BuildSPD(account, amount, message, defaultAccount string) string {
|
||||
if !validAccount.MatchString(account) {
|
||||
account = defaultAccount
|
||||
}
|
||||
|
||||
var amtStr string
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(amount, "%f", &f); err != nil || f < 0 || f > 10_000_000 {
|
||||
amtStr = "0.00"
|
||||
} else {
|
||||
amtStr = fmt.Sprintf("%.2f", f)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(message) > 60 {
|
||||
runes := []rune(message)
|
||||
message = string(runes[:60])
|
||||
}
|
||||
message = strings.ReplaceAll(message, "*", "")
|
||||
|
||||
var accStr string
|
||||
if parts := strings.SplitN(account, "/", 2); len(parts) == 2 {
|
||||
accStr = parts[0] + "*BC:" + parts[1]
|
||||
} else {
|
||||
accStr = account
|
||||
}
|
||||
|
||||
return fmt.Sprintf("SPD*1.0*ACC:%s*AM:%s*CC:CZK*MSG:%s", accStr, amtStr, message)
|
||||
}
|
||||
|
||||
// RenderQRCode encodes payload as a PNG QR code (256×256, error correction Medium).
|
||||
func RenderQRCode(payload string) ([]byte, error) {
|
||||
return qrcode.Encode(payload, qrcode.Medium, 256)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user