Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+28
-1
@@ -10,7 +10,34 @@
|
|||||||
"Bash(./bin/fuj help *)",
|
"Bash(./bin/fuj help *)",
|
||||||
"Bash(./bin/fuj version *)",
|
"Bash(./bin/fuj version *)",
|
||||||
"Bash(make go-test *)",
|
"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 }}
|
TAG=${{ inputs.tag }}
|
||||||
fi
|
fi
|
||||||
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
||||||
docker build -f build/Dockerfile \
|
docker build -f legacy/build/Dockerfile \
|
||||||
--build-arg GIT_TAG=$TAG \
|
--build-arg GIT_TAG=$TAG \
|
||||||
--build-arg GIT_COMMIT=${{ github.sha }} \
|
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||||
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
-t $IMAGE .
|
-t $IMAGE legacy/
|
||||||
docker push $IMAGE
|
docker push $IMAGE
|
||||||
|
|
||||||
build-go:
|
build-go:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
name: GitOps image update
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Auto-fires when "Build and Push" completes successfully (tag push).
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build and Push"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
# Manual trigger for dry-runs and one-off bumps.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Git tag to deploy (without the -go suffix, e.g. 0.37)"
|
||||||
|
required: true
|
||||||
|
dry_run:
|
||||||
|
description: "Dry run — print diff, do not open a PR"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
uh_cli_version:
|
||||||
|
description: "uh-cli version override (e.g. v0.2.0). Defaults to v0.1.0."
|
||||||
|
required: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
TEA_VERSION: "0.9.2"
|
||||||
|
# Resolved priority: manual input → repo/org variable → hardcoded default.
|
||||||
|
UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitops-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Skip if triggered by workflow_run that did not succeed.
|
||||||
|
if: >
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
github.event.workflow_run.conclusion == 'success'
|
||||||
|
container:
|
||||||
|
image: ubuntu:latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install git, curl, ca-certificates, jq
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends git curl ca-certificates jq
|
||||||
|
|
||||||
|
- name: Install tea
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \
|
||||||
|
-o /usr/local/bin/tea
|
||||||
|
chmod +x /usr/local/bin/tea
|
||||||
|
|
||||||
|
- name: Install uh-cli
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64" \
|
||||||
|
-o /usr/local/bin/uh-cli
|
||||||
|
chmod +x /usr/local/bin/uh-cli
|
||||||
|
|
||||||
|
- name: Resolve image tag
|
||||||
|
id: resolve
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${{ inputs.tag }}-go"
|
||||||
|
else
|
||||||
|
# workflow_run: head_branch is not populated for tag pushes in Gitea Actions.
|
||||||
|
# Look up the tag name that points to the triggering commit SHA via the API.
|
||||||
|
SHA="${{ github.event.workflow_run.head_sha }}"
|
||||||
|
GIT_TAG=$(curl -fsSL \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"https://gitea.home.hrajfrisbee.cz/api/v1/repos/${{ github.repository }}/tags?limit=50" \
|
||||||
|
| jq -r --arg sha "$SHA" '.[] | select(.commit.sha == $sha) | .name')
|
||||||
|
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
|
||||||
|
fi
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved image: ${IMAGE}"
|
||||||
|
|
||||||
|
- name: Configure git identity and credentials
|
||||||
|
run: |
|
||||||
|
git config --global user.name "uh-cli bot"
|
||||||
|
git config --global user.email "bot@hrajfrisbee.cz"
|
||||||
|
# Store credentials separately so the --git-repo URL stays clean.
|
||||||
|
# Tea matches the login URL against the remote URL; embedded credentials
|
||||||
|
# break that matching and cause "path segment [0] is empty" on pr create.
|
||||||
|
git config --global credential.helper store
|
||||||
|
echo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz" >> ~/.git-credentials
|
||||||
|
|
||||||
|
- name: Authenticate tea
|
||||||
|
run: |
|
||||||
|
tea login add \
|
||||||
|
--name ci \
|
||||||
|
--url https://gitea.home.hrajfrisbee.cz \
|
||||||
|
--token "$GITEA_TOKEN"
|
||||||
|
|
||||||
|
- name: Open image-update PR (or dry run)
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
uh-cli -v gitops deployment update \
|
||||||
|
--deployment-name fuj-management \
|
||||||
|
--deployment-namespace fuj \
|
||||||
|
--set-image "${{ steps.resolve.outputs.image }}" \
|
||||||
|
--git-repo "https://gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
|
||||||
|
--git-path gitops/home-kubernetes \
|
||||||
|
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}
|
||||||
@@ -7,3 +7,4 @@ tmp/
|
|||||||
|
|
||||||
# go build output
|
# go build output
|
||||||
bin/
|
bin/
|
||||||
|
go/parity
|
||||||
|
|||||||
Vendored
+5
-3
@@ -2,14 +2,16 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python Debugger: Flask",
|
"name": "Python Debugger: Flask (legacy)",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "flask",
|
"module": "flask",
|
||||||
"python": "${workspaceFolder}/.venv/bin/python",
|
"python": "${workspaceFolder}/legacy/.venv/bin/python",
|
||||||
|
"cwd": "${workspaceFolder}/legacy",
|
||||||
"env": {
|
"env": {
|
||||||
"FLASK_APP": "app.py",
|
"FLASK_APP": "app.py",
|
||||||
"FLASK_DEBUG": "1"
|
"FLASK_DEBUG": "1",
|
||||||
|
"CREDENTIALS_PATH": "${workspaceFolder}/.secret/fuj-management-bot-credentials.json"
|
||||||
},
|
},
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
|
|||||||
@@ -1,5 +1,48 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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"))`.
|
- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
|
||||||
|
|||||||
@@ -4,28 +4,62 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
|
Financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
|
||||||
|
|
||||||
|
- **Go is the primary implementation** (`go/`). It is the version that is built, deployed and developed first.
|
||||||
|
- **Python is legacy** (`legacy/`). The project was originally developed in Python (Flask); that version is kept runnable and in sync, but is considered legacy from now on.
|
||||||
|
|
||||||
|
## Dual implementation rule
|
||||||
|
|
||||||
|
Until the Python version is retired, **every new feature and every behaviour change must be implemented in both Go and Python**:
|
||||||
|
|
||||||
|
1. Implement and test in Go first (`go/`).
|
||||||
|
2. Mirror the change in the legacy Python code (`legacy/`), including its tests.
|
||||||
|
3. Keep the two in parity: shared constants (fee rates, merged months, bank accounts, sheet IDs) must match on both sides, and the JSON API contract (`/api/adults`, `/api/juniors`, `/api/payments`) must stay identical. The HTML frontends are allowed to diverge.
|
||||||
|
4. If a pure function's behaviour changes, add or update seeds in `legacy/scripts/_fixture_seeds.py`, run `make capture-fixtures`, and make sure `make go-test-all` passes. With both servers running, `make parity` diffs the live JSON APIs.
|
||||||
|
|
||||||
|
Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).
|
||||||
|
|
||||||
## Key Constraints
|
## Key Constraints
|
||||||
|
|
||||||
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
||||||
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
|
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `go/internal/config/config.go` (mirrored in `legacy/scripts/config.py`). Domain-specific constants (fees, merged months) stay in their respective modules.
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
This project uses `uv` for dependency management.
|
Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv venv && uv sync
|
make go-build # builds bin/fuj
|
||||||
source .venv/bin/activate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically).
|
Python (legacy) uses `uv`; the project file and virtualenv live in `legacy/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make venv # uv sync --project legacy → legacy/.venv
|
||||||
|
```
|
||||||
|
|
||||||
|
The Makefile sets `PYTHONPATH` (`legacy/scripts:legacy`) and `CREDENTIALS_PATH` (`.secret/fuj-management-bot-credentials.json` at the repo root) for the Python targets. Set both yourself when running Python scripts directly.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
Go (primary):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make web # Start dashboard at http://localhost:5001
|
make web-go # Build and start dashboard at http://localhost:8080
|
||||||
|
make go-build # Build bin/fuj
|
||||||
|
make go-test # Unit tests (go test -race ./...)
|
||||||
|
make go-parity # Parity tests against the captured Python fixtures
|
||||||
|
make go-test-all # Both of the above
|
||||||
|
make go-lint # golangci-lint
|
||||||
|
make go-run ARGS="fees" # Run a subcommand: server, fees, reconcile, sync, infer, version
|
||||||
|
make go-sync-debug [DAYS=N] # Dry-run bank sync with debug logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Python (legacy):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make web-py # Start dashboard at http://localhost:5001 (make web is an alias)
|
||||||
make web-debug # Same with FLASK_DEBUG=1
|
make web-debug # Same with FLASK_DEBUG=1
|
||||||
make test # Run all tests (unittest discover)
|
make test # Run all tests (unittest discover)
|
||||||
make test-v # Tests with verbose output
|
make test-v # Tests with verbose output
|
||||||
@@ -33,12 +67,20 @@ make fees # Print fee report from attendance sheet
|
|||||||
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
|
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
|
||||||
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
|
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
|
||||||
make reconcile # Print balance report from Google Sheets data
|
make reconcile # Print balance report from Google Sheets data
|
||||||
make image # Build Docker image
|
make image # Build the legacy Python Docker image
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-implementation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make capture-fixtures # Regenerate go/tests/fixtures from the Python implementation
|
||||||
|
make parity # Diff /api/* between web-py (:5001) and web-go (:8080); both must be running
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a single test:
|
Run a single test:
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route
|
cd go && go test ./internal/domain/reconcile -run TestName
|
||||||
|
cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -46,25 +88,45 @@ PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_ro
|
|||||||
### Data flow
|
### Data flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/
|
Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
|
||||||
Google Sheets (payments) ──► match_payments.py ──┘
|
Google Sheets (payments) ──► payments loader ──┘
|
||||||
Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments)
|
Fio Bank API ──► bank sync ──► Google Sheets (payments)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key modules
|
### Repository layout
|
||||||
|
|
||||||
- `app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`
|
- `go/` — primary implementation (single binary `fuj`, templates and static assets embedded).
|
||||||
- `scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
|
- `legacy/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`).
|
||||||
- `scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
|
- `docs/` — documentation; `docs/plans/` holds plans and task lists.
|
||||||
- `scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `tmp/`.
|
- `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR.
|
||||||
- `scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
|
|
||||||
- `scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
|
### Key modules — Go (primary)
|
||||||
- `scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `BANK_ACCOUNT`, `CACHE_TTL_SECONDS`).
|
|
||||||
|
- `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`.
|
||||||
|
- `go/cmd/parity` — diffs the JSON API of the Go and Python servers.
|
||||||
|
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
|
||||||
|
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
|
||||||
|
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
|
||||||
|
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference).
|
||||||
|
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
|
||||||
|
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
|
||||||
|
|
||||||
|
### Key modules — Python (legacy)
|
||||||
|
|
||||||
|
- `legacy/app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`, `/api/*`
|
||||||
|
- `legacy/scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
|
||||||
|
- `legacy/scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
|
||||||
|
- `legacy/scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `legacy/tmp/`.
|
||||||
|
- `legacy/scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
|
||||||
|
- `legacy/scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
|
||||||
|
- `legacy/scripts/views.py` — View models shared by the HTML routes and the JSON API.
|
||||||
|
- `legacy/scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `CACHE_TTL_SECONDS`, `MONTHS_TO_SHOW`).
|
||||||
|
- `legacy/scripts/capture_fixtures.py`, `scrub_fixtures.py`, `_fixture_seeds.py` — produce the scrubbed parity fixtures for Go.
|
||||||
|
|
||||||
### Member tiers
|
### Member tiers
|
||||||
|
|
||||||
Tiers are set in column B of the attendance sheet:
|
Tiers are set in column B of the attendance sheet:
|
||||||
- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
|
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
|
||||||
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
||||||
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
||||||
|
|
||||||
@@ -76,17 +138,17 @@ Tiers are set in column B of the attendance sheet:
|
|||||||
|
|
||||||
### Merged months
|
### Merged months
|
||||||
|
|
||||||
`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `attendance.py` map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
|
`AdultMergedMonths` / `JuniorMergedMonths` in `go/internal/services/membership/sources.go` (`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `legacy/scripts/attendance.py`) map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
|
||||||
|
|
||||||
### Caching
|
### Caching
|
||||||
|
|
||||||
`get_cached_data()` in `app.py` checks the Drive API `modifiedTime` before each request and serves a JSON file from `tmp/` when the sheet hasn't changed. Cache is warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST) or `flush_cache()`.
|
Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST).
|
||||||
|
|
||||||
### Payments sheet columns
|
### Payments sheet columns
|
||||||
|
|
||||||
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
|
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
|
||||||
|
|
||||||
`Person` and `Purpose` are written by `infer_payments.py` and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
|
`Person` and `Purpose` are written by the inference step (`fuj infer` / `infer_payments.py`) and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
|
||||||
|
|
||||||
### QR codes
|
### QR codes
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
|
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
|
||||||
|
|
||||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
# Legacy Python implementation lives in legacy/ (Go in go/ is primary).
|
||||||
VENV := .venv
|
LEGACY := legacy
|
||||||
PYTHON := $(VENV)/bin/python3
|
export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH)
|
||||||
CREDENTIALS := .secret/fuj-management-bot-credentials.json
|
VENV := $(LEGACY)/.venv
|
||||||
|
PYTHON := $(CURDIR)/$(VENV)/bin/python3
|
||||||
|
CREDENTIALS := $(CURDIR)/.secret/fuj-management-bot-credentials.json
|
||||||
|
# Both implementations read CREDENTIALS_PATH; the Python default would resolve under legacy/.
|
||||||
|
export CREDENTIALS_PATH ?= $(CREDENTIALS)
|
||||||
|
|
||||||
GO_SRC := go
|
GO_SRC := go
|
||||||
GO_BIN := bin/fuj
|
GO_BIN := bin/fuj
|
||||||
|
|
||||||
$(PYTHON): .venv/.last_sync
|
$(PYTHON): $(VENV)/.last_sync
|
||||||
|
|
||||||
.venv/.last_sync: pyproject.toml
|
$(VENV)/.last_sync: $(LEGACY)/pyproject.toml
|
||||||
uv sync
|
uv sync --project $(LEGACY)
|
||||||
touch .venv/.last_sync
|
touch $(VENV)/.last_sync
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
||||||
@echo " make match - Match Fio bank payments against expected attendance fees"
|
@echo " make match - Match Fio bank payments against expected attendance fees"
|
||||||
@echo " make web - Start Python dashboard (alias for web-py, until M8)"
|
@echo " make web - Start legacy Python dashboard (alias for web-py)"
|
||||||
@echo " make web-py - Start Python dashboard on :5001"
|
@echo " make web-py - Start legacy Python dashboard on :5001"
|
||||||
@echo " make web-go - Build and start Go dashboard on :8080"
|
@echo " make web-go - Build and start Go dashboard on :8080 (primary)"
|
||||||
@echo " make web-debug - Start Python dashboard in debug mode"
|
@echo " make web-debug - Start Python dashboard in debug mode"
|
||||||
@echo " make go-build - Build Go binary to bin/fuj"
|
@echo " make go-build - Build Go binary to bin/fuj"
|
||||||
@echo " make go-test - Run Go unit tests"
|
@echo " make go-test - Run Go unit tests"
|
||||||
@@ -35,6 +39,7 @@ help:
|
|||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
||||||
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
||||||
|
@echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)"
|
||||||
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||||
@echo " make venv - Sync virtual environment with pyproject.toml"
|
@echo " make venv - Sync virtual environment with pyproject.toml"
|
||||||
@@ -43,21 +48,21 @@ help:
|
|||||||
@echo " make docs - Serve documentation in a browser"
|
@echo " make docs - Serve documentation in a browser"
|
||||||
|
|
||||||
venv:
|
venv:
|
||||||
uv sync
|
uv sync --project $(LEGACY)
|
||||||
|
|
||||||
fees: $(PYTHON)
|
fees: $(PYTHON)
|
||||||
$(PYTHON) scripts/calculate_fees.py
|
$(PYTHON) $(LEGACY)/scripts/calculate_fees.py
|
||||||
|
|
||||||
match: $(PYTHON)
|
match: $(PYTHON)
|
||||||
$(PYTHON) scripts/match_payments.py
|
$(PYTHON) $(LEGACY)/scripts/match_payments.py
|
||||||
|
|
||||||
web: web-py
|
web: web-py
|
||||||
|
|
||||||
web-py: $(PYTHON)
|
web-py: $(PYTHON)
|
||||||
$(PYTHON) app.py
|
$(PYTHON) $(LEGACY)/app.py
|
||||||
|
|
||||||
web-debug: $(PYTHON)
|
web-debug: $(PYTHON)
|
||||||
FLASK_DEBUG=1 $(PYTHON) app.py
|
FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py
|
||||||
|
|
||||||
go-build:
|
go-build:
|
||||||
cd $(GO_SRC) && go build -trimpath \
|
cd $(GO_SRC) && go build -trimpath \
|
||||||
@@ -81,10 +86,10 @@ capture-fixtures: $(PYTHON)
|
|||||||
infer_transaction_details format_date reconcile; do \
|
infer_transaction_details format_date reconcile; do \
|
||||||
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
|
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
|
||||||
mkdir -p "$$dir"; \
|
mkdir -p "$$dir"; \
|
||||||
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \
|
$(PYTHON) $(LEGACY)/scripts/capture_fixtures.py --func $$func --all \
|
||||||
| while IFS= read -r line; do \
|
| while IFS= read -r line; do \
|
||||||
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
|
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
|
||||||
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
|
echo "$$line" | $(PYTHON) $(LEGACY)/scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
|
||||||
done; \
|
done; \
|
||||||
echo " $$func done"; \
|
echo " $$func done"; \
|
||||||
done
|
done
|
||||||
@@ -111,31 +116,34 @@ image:
|
|||||||
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||||
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
|
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
|
||||||
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
-f build/Dockerfile .
|
-f $(LEGACY)/build/Dockerfile $(LEGACY)
|
||||||
|
|
||||||
run:
|
run:
|
||||||
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
||||||
|
|
||||||
sync: $(PYTHON)
|
sync: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
|
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
sync-2025: $(PYTHON)
|
sync-2025: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date
|
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2025-10-01 --to 2025-12-31 --sort-by-date
|
||||||
|
|
||||||
sync-2026: $(PYTHON)
|
sync-2026: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||||
|
|
||||||
|
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)
|
infer: $(PYTHON)
|
||||||
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
reconcile: ## Match payments against attendance
|
reconcile: $(PYTHON) ## Match payments against attendance
|
||||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
test: $(PYTHON) ## Run web application tests
|
test: $(PYTHON) ## Run web application tests
|
||||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests
|
cd $(LEGACY) && $(PYTHON) -m unittest discover tests
|
||||||
|
|
||||||
test-v: $(PYTHON) ## Run tests with verbose output
|
test-v: $(PYTHON) ## Run tests with verbose output
|
||||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
|
cd $(LEGACY) && $(PYTHON) -m unittest discover -v tests
|
||||||
|
|
||||||
docs: ## Serve documentation locally
|
docs: ## Serve documentation locally
|
||||||
@echo "Starting documentation server at http://localhost:8000"
|
@echo "Starting documentation server at http://localhost:8000"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Welcome to the documentation for the FUJ Management application.
|
|||||||
|
|
||||||
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
|
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
|
||||||
|
|
||||||
|
> **Go is the primary implementation** (`go/`). The project was originally developed in Python; that version now lives in `legacy/` and is considered legacy. New features are still built in both. Most guides below predate this change and describe the Python version with its old paths (`app.py`, `scripts/` are now `legacy/app.py`, `legacy/scripts/`).
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Use the sidebar to explore the documentation:
|
Use the sidebar to explore the documentation:
|
||||||
|
|||||||
@@ -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.)
|
||||||
+14
-4
@@ -88,7 +88,7 @@ func serverCmd(args []string) {
|
|||||||
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
|
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
|
fioClients := buildFioClients(cfg)
|
||||||
|
|
||||||
actions := web.ActionHandlers{
|
actions := web.ActionHandlers{
|
||||||
BankSync: func(ctx context.Context, out io.Writer) error {
|
BankSync: func(ctx context.Context, out io.Writer) error {
|
||||||
@@ -97,7 +97,7 @@ func serverCmd(args []string) {
|
|||||||
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
|
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||||
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli,
|
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
|
||||||
banksync.SyncOpts{From: from, To: to, Sort: true})
|
banksync.SyncOpts{From: from, To: to, Sort: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("sync: %w", err)
|
return fmt.Errorf("sync: %w", err)
|
||||||
@@ -197,7 +197,7 @@ func syncCmd(args []string) {
|
|||||||
fmt.Fprintf(os.Stderr, "fuj sync: sheets client: %v\n", err)
|
fmt.Fprintf(os.Stderr, "fuj sync: sheets client: %v\n", err)
|
||||||
os.Exit(1)
|
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}
|
opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable}
|
||||||
if *fromStr != "" && *toStr != "" {
|
if *fromStr != "" && *toStr != "" {
|
||||||
@@ -213,7 +213,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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
|
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -274,3 +274,13 @@ Commands:
|
|||||||
sync Sync Fio transactions to payments sheet
|
sync Sync Fio transactions to payments sheet
|
||||||
infer Infer payment details in 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
|
|||||||
|
|
||||||
// defaultAllowlist holds dotted key paths to strip before diffing.
|
// defaultAllowlist holds dotted key paths to strip before diffing.
|
||||||
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
||||||
// template context only (app.py inject_render_time) and is logged via
|
// template context only (legacy/app.py inject_render_time) and is logged via
|
||||||
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
|
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
|
||||||
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
||||||
var defaultAllowlist = []string{"render_time.total"}
|
var defaultAllowlist = []string{"render_time.total"}
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ import (
|
|||||||
"time"
|
"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).
|
// Google Sheets IDs — change in code if sheets change (not from env).
|
||||||
const (
|
const (
|
||||||
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||||
@@ -20,7 +42,7 @@ const (
|
|||||||
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
|
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
|
||||||
)
|
)
|
||||||
|
|
||||||
// CacheSheetMap mirrors scripts/config.py CACHE_SHEET_MAP.
|
// CacheSheetMap mirrors legacy/scripts/config.py CACHE_SHEET_MAP.
|
||||||
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
|
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
|
||||||
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
|
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
|
||||||
var CacheSheetMap = map[string]string{
|
var CacheSheetMap = map[string]string{
|
||||||
@@ -31,32 +53,42 @@ var CacheSheetMap = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Config holds all runtime configuration loaded from environment variables.
|
// Config holds all runtime configuration loaded from environment variables.
|
||||||
// Mirrors scripts/config.py.
|
// Mirrors legacy/scripts/config.py.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
CredentialsPath string
|
CredentialsPath string
|
||||||
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
|
CacheDir string
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
CacheAPICheckTTL time.Duration
|
CacheAPICheckTTL time.Duration
|
||||||
DriveTimeout time.Duration
|
DriveTimeout time.Duration
|
||||||
LogLevel string
|
LogLevel string
|
||||||
FioAPIToken string
|
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
|
MonthsToShow int // show last N month columns; 0 means show all
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from the environment, applying defaults that
|
// Load reads configuration from the environment, applying defaults that
|
||||||
// match the Python side.
|
// match the Python side.
|
||||||
func Load() Config {
|
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{
|
return Config{
|
||||||
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
||||||
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
QRAccount: qrAccount,
|
||||||
|
LoadedAccounts: loaded,
|
||||||
CacheDir: env("CACHE_DIR", "tmp/go"),
|
CacheDir: env("CACHE_DIR", "tmp/go"),
|
||||||
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
||||||
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
||||||
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
||||||
LogLevel: env("LOG_LEVEL", "INFO"),
|
LogLevel: env("LOG_LEVEL", "INFO"),
|
||||||
FioAPIToken: env("FIO_API_TOKEN", ""),
|
|
||||||
ServerAddr: env("SERVER_ADDR", ":8080"),
|
ServerAddr: env("SERVER_ADDR", ":8080"),
|
||||||
|
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,3 +123,12 @@ func envDuration(key string, defaultSeconds int) time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(defaultSeconds) * time.Second
|
return time.Duration(defaultSeconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envInt(key string, fallback int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func TestParseMonthReferences(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-05:
|
// All expected outputs verified against live Python implementation on 2026-05-05:
|
||||||
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
|
// PYTHONPATH=legacy/scripts:legacy python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package fees ports fee calculation from scripts/attendance.py.
|
// Package fees ports fee calculation from legacy/scripts/attendance.py.
|
||||||
package fees
|
package fees
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -6,12 +6,12 @@ const (
|
|||||||
AdultFeeSingle = 200 // CZK for exactly 1 practice
|
AdultFeeSingle = 200 // CZK for exactly 1 practice
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py.
|
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in legacy/scripts/attendance.py.
|
||||||
// Months absent from this map fall back to AdultFeeDefault.
|
// Months absent from this map fall back to AdultFeeDefault.
|
||||||
var AdultFeeMonthlyRate = map[string]int{
|
var AdultFeeMonthlyRate = map[string]int{
|
||||||
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
||||||
"2026-01": 750, "2026-02": 750, "2026-03": 350,
|
"2026-01": 750, "2026-02": 750, "2026-03": 350,
|
||||||
"2026-04": 700, "2026-05": 700,
|
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 600, "2026-08": 600,
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
|
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
|
||||||
|
|||||||
@@ -5,24 +5,40 @@ import "testing"
|
|||||||
func TestCalculateFee(t *testing.T) {
|
func TestCalculateFee(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// mustRate returns the configured rate for a month that must be in the map.
|
||||||
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
|
// It panics immediately if the month is absent — so if a rate entry is ever
|
||||||
|
// removed, the test fails loudly rather than silently comparing against
|
||||||
|
// Go's zero value.
|
||||||
|
mustRate := func(month string) int {
|
||||||
|
r, ok := AdultFeeMonthlyRate[month]
|
||||||
|
if !ok {
|
||||||
|
panic("test month not in AdultFeeMonthlyRate: " + month)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
count int
|
count int
|
||||||
month string
|
month string
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
|
// Zero attendance always returns 0.
|
||||||
{"zero short-circuits", 0, "2026-05", 0},
|
{"zero short-circuits", 0, "2026-05", 0},
|
||||||
{"zero empty month", 0, "", 0},
|
{"zero empty month", 0, "", 0},
|
||||||
{"single practice", 1, "2026-05", 200},
|
// Single practice returns AdultFeeSingle regardless of month.
|
||||||
{"single ignores monthKey", 1, "unknown", 200},
|
{"single practice", 1, "2026-05", AdultFeeSingle},
|
||||||
{"two practices configured month", 2, "2026-05", 700},
|
{"single ignores monthKey", 1, "unknown", AdultFeeSingle},
|
||||||
{"two practices reduced march", 2, "2026-03", 350},
|
// Two+ practices for a configured month: must use the map value, not the default.
|
||||||
{"two practices early season", 2, "2025-09", 750},
|
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
|
||||||
{"high count same as two", 5, "2026-05", 700},
|
// when rates are updated — the assertion verifies dispatch logic, not rate values.
|
||||||
{"unknown future month falls back", 2, "2027-01", 700},
|
{"two practices configured month", 2, "2026-05", mustRate("2026-05")},
|
||||||
{"empty month falls back", 2, "", 700},
|
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
|
||||||
|
{"two practices early season", 2, "2025-09", mustRate("2025-09")},
|
||||||
|
{"high count same as two", 5, "2026-05", mustRate("2026-05")},
|
||||||
|
// Two+ practices for an unknown/future month: must fall back to AdultFeeDefault.
|
||||||
|
{"unknown future month falls back", 2, "2027-01", AdultFeeDefault},
|
||||||
|
{"empty month falls back", 2, "", AdultFeeDefault},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package fees
|
|||||||
|
|
||||||
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
|
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
|
||||||
|
|
||||||
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py.
|
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in legacy/scripts/attendance.py.
|
||||||
// Months absent from this map fall back to JuniorFeeDefault.
|
// Months absent from this map fall back to JuniorFeeDefault.
|
||||||
var JuniorFeeMonthlyRate = map[string]int{
|
var JuniorFeeMonthlyRate = map[string]int{
|
||||||
"2025-09": 250,
|
"2025-09": 250,
|
||||||
|
|||||||
@@ -5,24 +5,37 @@ import "testing"
|
|||||||
func TestCalculateJuniorFee(t *testing.T) {
|
func TestCalculateJuniorFee(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// mustRate returns the configured rate for a month that must be in the map.
|
||||||
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
|
// Panics immediately if the month is absent so a removed entry causes a loud
|
||||||
|
// failure rather than a silent comparison against Go's zero value.
|
||||||
|
mustRate := func(month string) Expected {
|
||||||
|
r, ok := JuniorFeeMonthlyRate[month]
|
||||||
|
if !ok {
|
||||||
|
panic("test month not in JuniorFeeMonthlyRate: " + month)
|
||||||
|
}
|
||||||
|
return Expected{Value: r}
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
count int
|
count int
|
||||||
month string
|
month string
|
||||||
want Expected
|
want Expected
|
||||||
}{
|
}{
|
||||||
|
// Zero attendance always returns 0.
|
||||||
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
|
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
|
||||||
{"zero empty month", 0, "", Expected{Value: 0}},
|
{"zero empty month", 0, "", Expected{Value: 0}},
|
||||||
|
// Single practice returns the Unknown sentinel regardless of month.
|
||||||
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
|
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
|
||||||
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
|
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
|
||||||
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
|
// Two+ practices for a configured month: must use the map value, not the default.
|
||||||
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
|
{"two practices unconfigured month", 2, "2026-05", Expected{Value: JuniorFeeDefault}},
|
||||||
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
|
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
|
||||||
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
|
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
|
||||||
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
|
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
|
||||||
{"empty month falls back", 2, "", Expected{Value: 500}},
|
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault.
|
||||||
|
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
|
||||||
|
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Package matching ports name/member matching from scripts/match_payments.py.
|
// Package matching ports name/member matching from legacy/scripts/match_payments.py.
|
||||||
package matching
|
package matching
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
|
|||||||
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
|
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
|
||||||
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
|
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py format_date.
|
// Ports legacy/scripts/match_payments.py format_date.
|
||||||
func FormatDate(val any) string {
|
func FormatDate(val any) string {
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
package matching
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c '
|
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||||
// from match_payments import format_date
|
// from match_payments import format_date
|
||||||
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
|
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
|
||||||
// print(repr(format_date(v)))
|
// print(repr(format_date(v)))
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type InferredDetails struct {
|
|||||||
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
|
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
|
||||||
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
|
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py infer_transaction_details.
|
// Ports legacy/scripts/match_payments.py infer_transaction_details.
|
||||||
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
||||||
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
package matching
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 << 'EOF'
|
// PYTHONPATH=legacy/scripts:legacy python3 << 'EOF'
|
||||||
// from match_payments import infer_transaction_details
|
// from match_payments import infer_transaction_details
|
||||||
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
|
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
|
||||||
// cases = [
|
// cases = [
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ var commonSurnames = map[string]bool{
|
|||||||
// MatchMembers finds members mentioned in text and returns them with a
|
// MatchMembers finds members mentioned in text and returns them with a
|
||||||
// confidence level of "auto" (reliable) or "review" (needs human verification).
|
// confidence level of "auto" (reliable) or "review" (needs human verification).
|
||||||
//
|
//
|
||||||
// Algorithm (ported verbatim from scripts/match_payments.py match_members):
|
// Algorithm (ported verbatim from legacy/scripts/match_payments.py match_members):
|
||||||
// 1. Exact short-circuit: if any member's full normalized name appears as whole
|
// 1. Exact short-circuit: if any member's full normalized name appears as whole
|
||||||
// words in normalize(text), return ONLY those matches as auto. This prevents
|
// words in normalize(text), return ONLY those matches as auto. This prevents
|
||||||
// nickname "tov" from matching inside surname "ottova".
|
// nickname "tov" from matching inside surname "ottova".
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package matching
|
package matching
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py and
|
// Expected values verified against legacy/scripts/match_payments.py and
|
||||||
// tests/test_match_members.py on 2026-05-06:
|
// tests/test_match_members.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c '
|
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||||
// from match_payments import match_members
|
// from match_payments import match_members
|
||||||
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
|
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
|
||||||
// cases = [
|
// cases = [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var (
|
|||||||
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
|
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
|
||||||
// dropped.
|
// dropped.
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py _build_name_variants.
|
// Ports legacy/scripts/match_payments.py _build_name_variants.
|
||||||
func BuildNameVariants(name string) []string {
|
func BuildNameVariants(name string) []string {
|
||||||
var nickname string
|
var nickname string
|
||||||
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
package matching
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c '
|
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||||
// from match_payments import _build_name_variants
|
// from match_payments import _build_name_variants
|
||||||
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
|
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
|
||||||
// print(repr(n), "->", _build_name_variants(n))
|
// print(repr(n), "->", _build_name_variants(n))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py.
|
// Package money ports Czech-locale currency parsing from legacy/scripts/infer_payments.py.
|
||||||
package money
|
package money
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
||||||
|
|
||||||
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
|
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
|
||||||
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount:
|
// as a float64. Mirrors legacy/scripts/infer_payments.py parse_czk_amount:
|
||||||
//
|
//
|
||||||
// - empty input → (0, nil)
|
// - empty input → (0, nil)
|
||||||
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
|
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ func TestParseCZK(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// All expected outputs verified against live Python implementation on 2026-05-06:
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from infer_payments import parse_czk_amount
|
// from infer_payments import parse_czk_amount
|
||||||
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
||||||
// "1 500", "1500.00", "1 500.00",
|
// "1 500", "1500.00", "1 500.00",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py.
|
// Package reconcile ports the three-phase payment reconciliation from legacy/scripts/match_payments.py.
|
||||||
package reconcile
|
package reconcile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -105,7 +105,7 @@ var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
|||||||
|
|
||||||
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
||||||
// used to resolve Person-column values that drift from canonical attendance-sheet names.
|
// used to resolve Person-column values that drift from canonical attendance-sheet names.
|
||||||
// Ports scripts/match_payments.py canonical_member_key.
|
// Ports legacy/scripts/match_payments.py canonical_member_key.
|
||||||
func canonicalMemberKey(name string) string {
|
func canonicalMemberKey(name string) string {
|
||||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
}
|
}
|
||||||
@@ -115,15 +115,16 @@ type monthExpected struct {
|
|||||||
expected int
|
expected int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile matches transactions to members and months using three allocation phases:
|
// Reconcile matches transactions to members and months using two allocation phases:
|
||||||
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
|
// 1. Fill-first: iterate matched months in user-supplied order, allocating min(remaining,
|
||||||
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
|
// deficit) to each month where deficit = expected − already-paid. Surplus → credit.
|
||||||
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
// 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.
|
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
||||||
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py reconcile.
|
// Ports legacy/scripts/match_payments.py reconcile.
|
||||||
func Reconcile(
|
func Reconcile(
|
||||||
members []Member,
|
members []Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
@@ -317,34 +318,26 @@ func Reconcile(
|
|||||||
totalExpected += mw.expected
|
totalExpected += mw.expected
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
|
if totalExpected > 0 {
|
||||||
// Greedy: payment covers all expected fees; overflow → credit
|
// Fill-first: iterate inWindow in matched-months order (chronological by
|
||||||
credits[memberName] += int(inWindowShare - float64(totalExpected))
|
// convention), allocating min(remaining, deficit) to each month. Deficit
|
||||||
for _, mw := range inWindow {
|
// is net of what prior transactions already paid, so a second payment on
|
||||||
alloc := float64(mw.expected)
|
// the same months correctly fills only what remains due. Any surplus after
|
||||||
md := ledger[memberName][mw.month]
|
// all deficits are covered goes to the credit bucket.
|
||||||
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
|
|
||||||
remaining := inWindowShare
|
remaining := inWindowShare
|
||||||
for i, mw := range inWindow {
|
for _, mw := range inWindow {
|
||||||
var alloc float64
|
|
||||||
if i == len(inWindow)-1 {
|
|
||||||
alloc = remaining
|
|
||||||
} else {
|
|
||||||
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
|
|
||||||
}
|
|
||||||
remaining -= alloc
|
|
||||||
md := ledger[memberName][mw.month]
|
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.Paid += alloc
|
||||||
md.Transactions = append(md.Transactions, TxEntry{
|
md.Transactions = append(md.Transactions, TxEntry{
|
||||||
Amount: alloc,
|
Amount: alloc,
|
||||||
@@ -354,6 +347,10 @@ func Reconcile(
|
|||||||
Confidence: string(m.Confidence),
|
Confidence: string(m.Confidence),
|
||||||
})
|
})
|
||||||
ledger[memberName][mw.month] = md
|
ledger[memberName][mw.month] = md
|
||||||
|
remaining -= alloc
|
||||||
|
}
|
||||||
|
if remaining > 0 {
|
||||||
|
credits[memberName] += int(remaining)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Even-split fallback: prepayment before attendance recorded
|
// Even-split fallback: prepayment before attendance recorded
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package reconcile
|
package reconcile
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
|
// PYTHONPATH=legacy/scripts:legacy python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
|
||||||
//
|
//
|
||||||
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
|
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
|
||||||
|
|
||||||
@@ -111,36 +111,26 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileProportionalUnderpayment(t *testing.T) {
|
func TestReconcileUnderpaymentFillsEarliestFirst(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
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}},
|
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"}
|
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
|
months := result.Members["Alice"].Months
|
||||||
paid02 := months["2026-02"].Paid
|
// 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||||
paid03 := months["2026-03"].Paid
|
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
|
||||||
paid04 := months["2026-04"].Paid
|
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
|
||||||
|
|
||||||
if paid02 >= 750 {
|
|
||||||
t.Errorf("2026-02 should be underpaid, got %f", paid02)
|
|
||||||
}
|
}
|
||||||
if paid03 >= 350 {
|
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
|
||||||
t.Errorf("2026-03 should be underpaid, got %f", paid03)
|
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
|
||||||
}
|
}
|
||||||
if paid04 >= 750 {
|
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
|
||||||
t.Errorf("2026-04 should be underpaid, got %f", paid04)
|
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,3 +364,52 @@ func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
|||||||
t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched)
|
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
|
// Package synch ports the bank-sync deduplication helper from
|
||||||
// scripts/sync_fio_to_sheets.py.
|
// legacy/scripts/sync_fio_to_sheets.py.
|
||||||
package synch
|
package synch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,7 +30,7 @@ type Transaction struct {
|
|||||||
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
||||||
// as the dedup key in column K of the payments sheet.
|
// as the dedup key in column K of the payments sheet.
|
||||||
//
|
//
|
||||||
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
|
// Byte-stable with legacy/scripts/sync_fio_to_sheets.py generate_sync_id.
|
||||||
func GenerateSyncID(tx Transaction) string {
|
func GenerateSyncID(tx Transaction) string {
|
||||||
currency := tx.Currency
|
currency := tx.Currency
|
||||||
if currency == "" {
|
if currency == "" {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
// All expected digests verified against the live Python implementation on 2026-05-06:
|
// All expected digests verified against the live Python implementation on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from sync_fio_to_sheets import generate_sync_id
|
// from sync_fio_to_sheets import generate_sync_id
|
||||||
// cases = [
|
// cases = [
|
||||||
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
||||||
@@ -88,7 +88,7 @@ func TestGenerateSyncID(t *testing.T) {
|
|||||||
|
|
||||||
// All expected strings verified against the live Python implementation on 2026-05-06:
|
// All expected strings verified against the live Python implementation on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
|
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
|
||||||
// print(repr(v), "->", repr(str(v)))
|
// print(repr(v), "->", repr(str(v)))
|
||||||
// '
|
// '
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
|
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
|
||||||
// mirroring scripts/cache_utils.py.
|
// mirroring legacy/scripts/cache_utils.py.
|
||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,7 +26,7 @@ type cacheFile struct {
|
|||||||
|
|
||||||
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
|
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
|
||||||
//
|
//
|
||||||
// Two TTL knobs mirror scripts/cache_utils.py:
|
// Two TTL knobs mirror legacy/scripts/cache_utils.py:
|
||||||
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
|
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
|
||||||
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
|
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type httpDoer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiClient fetches transactions from the Fio REST API (JSON).
|
// apiClient fetches transactions from the Fio REST API (JSON).
|
||||||
// Ports scripts/fio_utils.py fetch_transactions_api.
|
// Ports legacy/scripts/fio_utils.py fetch_transactions_api.
|
||||||
type apiClient struct {
|
type apiClient struct {
|
||||||
token string
|
token string
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// transparentClient fetches transactions from the Fio transparent account page (HTML).
|
// transparentClient fetches transactions from the Fio transparent account page (HTML).
|
||||||
// Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
|
// Ports legacy/scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
|
||||||
type transparentClient struct {
|
type transparentClient struct {
|
||||||
accountNum string
|
accountNum string
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type sheetReadWriter interface {
|
|||||||
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
|
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
|
||||||
// sheet using name and month matching against the member list.
|
// sheet using name and month matching against the member list.
|
||||||
// Returns the number of rows updated (or that would be updated on dry-run).
|
// Returns the number of rows updated (or that would be updated on dry-run).
|
||||||
// Ports scripts/infer_payments.py infer_payments.
|
// Ports legacy/scripts/infer_payments.py infer_payments.
|
||||||
func InferPayments(
|
func InferPayments(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
spreadsheetID string,
|
spreadsheetID string,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// columnLabels is the canonical header for the payments sheet.
|
// columnLabels is the canonical header for the payments sheet.
|
||||||
// Mirrors COLUMN_LABELS in scripts/sync_fio_to_sheets.py.
|
// Mirrors COLUMN_LABELS in legacy/scripts/sync_fio_to_sheets.py.
|
||||||
var columnLabels = []string{
|
var columnLabels = []string{
|
||||||
"Date", "Amount", "manual fix", "Person", "Purpose",
|
"Date", "Amount", "manual fix", "Person", "Purpose",
|
||||||
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
|
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
|
||||||
@@ -35,13 +35,13 @@ type SyncOpts struct {
|
|||||||
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
|
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncToSheets fetches Fio transactions and appends new ones to the payments sheet.
|
// SyncToSheets fetches Fio transactions from all provided clients and appends
|
||||||
// Returns the number of rows appended.
|
// new ones to the payments sheet. Returns the number of rows appended.
|
||||||
// Ports scripts/sync_fio_to_sheets.py sync_to_sheets.
|
// Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
|
||||||
func SyncToSheets(
|
func SyncToSheets(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
spreadsheetID string,
|
spreadsheetID string,
|
||||||
fioClient fio.Client,
|
fioClients []fio.Client,
|
||||||
sh sheetsWriter,
|
sh sheetsWriter,
|
||||||
opts SyncOpts,
|
opts SyncOpts,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
@@ -84,10 +84,14 @@ func SyncToSheets(
|
|||||||
from = to.AddDate(0, 0, -days)
|
from = to.AddDate(0, 0, -days)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch Fio transactions.
|
// 3. Fetch Fio transactions from each account and combine.
|
||||||
txns, err := fioClient.FetchTransactions(ctx, from, to)
|
var txns []fio.Transaction
|
||||||
if err != nil {
|
for _, client := range fioClients {
|
||||||
return 0, fmt.Errorf("sync: fetch fio: %w", err)
|
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 {
|
if opts.DryRun {
|
||||||
fmt.Printf("Dry run: window %s to %s, fetched %d transaction(s) from Fio\n",
|
fmt.Printf("Dry run: window %s to %s, fetched %d transaction(s) from Fio\n",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
fioFake := &fio.Fake{Transactions: testFioTxns}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func TestSyncToSheets_Dedup(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
fioFake := &fio.Fake{Transactions: testFioTxns}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func TestSyncToSheets_NoNewTxns(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
fioFake := &fio.Fake{Transactions: testFioTxns}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func TestSyncToSheets_MissingHeader(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func TestSyncToSheets_Sort(t *testing.T) {
|
|||||||
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
|
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
|
||||||
fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ func TestSyncToSheets_ExplicitDateWindow(t *testing.T) {
|
|||||||
|
|
||||||
from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||||
to := time.Date(2026, 4, 30, 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ func TestSyncToSheets_DryRun(t *testing.T) {
|
|||||||
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
|
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
|
||||||
fioFake := &fio.Fake{Transactions: testFioTxns}
|
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})
|
SyncOpts{Days: 30, Sort: true, DryRun: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -144,6 +144,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.
|
// syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction.
|
||||||
func syncIDFor(tx fio.Transaction) string {
|
func syncIDFor(tx fio.Transaction) string {
|
||||||
currency := tx.Currency
|
currency := tx.Currency
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// printFeesTable writes a fixed-width adult-fees table to w.
|
// printFeesTable writes a fixed-width adult-fees table to w.
|
||||||
// Mirrors scripts/calculate_fees.py main().
|
// Mirrors legacy/scripts/calculate_fees.py main().
|
||||||
//
|
//
|
||||||
// Verify with:
|
// Verify with:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
|
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
|
||||||
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
|
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
|
||||||
type row struct {
|
type row struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package membership
|
package membership
|
||||||
|
|
||||||
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06:
|
// Golden strings verified against legacy/scripts/calculate_fees.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
|
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
|
||||||
//
|
//
|
||||||
// (feed equivalent fixture data via attendance sheet or local CSV)
|
// (feed equivalent fixture data via attendance sheet or local CSV)
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// printReconcileReport writes the full balance report to w.
|
// printReconcileReport writes the full balance report to w.
|
||||||
// Mirrors scripts/match_payments.py print_report().
|
// Mirrors legacy/scripts/match_payments.py print_report().
|
||||||
//
|
//
|
||||||
// Verify with:
|
// Verify with:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
|
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
|
||||||
// ...'
|
// ...'
|
||||||
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
|
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package membership
|
package membership
|
||||||
|
|
||||||
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06:
|
// Golden strings verified against legacy/scripts/match_payments.py print_report() on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from match_payments import print_report
|
// from match_payments import print_report
|
||||||
// result = {
|
// result = {
|
||||||
// "members": {
|
// "members": {
|
||||||
|
|||||||
@@ -18,20 +18,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py)
|
// Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py)
|
||||||
const (
|
const (
|
||||||
colName = 0
|
colName = 0
|
||||||
colTier = 1
|
colTier = 1
|
||||||
firstDateCol = 3
|
firstDateCol = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in legacy/scripts/attendance.py.
|
||||||
// Source month → target month (source attendance accumulated into target).
|
// Source month → target month (source attendance accumulated into target).
|
||||||
var AdultMergedMonths = map[string]string{
|
var AdultMergedMonths = map[string]string{
|
||||||
"2025-09": "2025-10",
|
"2025-09": "2025-10",
|
||||||
}
|
}
|
||||||
|
|
||||||
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in legacy/scripts/attendance.py.
|
||||||
var JuniorMergedMonths = map[string]string{
|
var JuniorMergedMonths = map[string]string{
|
||||||
"2025-12": "2026-01",
|
"2025-12": "2026-01",
|
||||||
"2025-09": "2025-10",
|
"2025-09": "2025-10",
|
||||||
@@ -127,11 +127,11 @@ func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.Excepti
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Attendance CSV parsing (ports scripts/attendance.py)
|
// Attendance CSV parsing (ports legacy/scripts/attendance.py)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
|
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
|
||||||
// Ports scripts/attendance.py parse_dates + strftime("%Y-%m").
|
// Ports legacy/scripts/attendance.py parse_dates + strftime("%Y-%m").
|
||||||
func parseDates(header []string) []struct {
|
func parseDates(header []string) []struct {
|
||||||
col int
|
col int
|
||||||
month string
|
month string
|
||||||
@@ -200,7 +200,7 @@ func countTrue(row []string, cols []int) int {
|
|||||||
|
|
||||||
// parseAdultRows converts raw CSV rows to []reconcile.Member.
|
// parseAdultRows converts raw CSV rows to []reconcile.Member.
|
||||||
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
|
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
|
||||||
// Ports scripts/attendance.py get_members_with_fees.
|
// Ports legacy/scripts/attendance.py get_members_with_fees.
|
||||||
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
||||||
if len(rows) < 2 {
|
if len(rows) < 2 {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
@@ -246,7 +246,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
|||||||
|
|
||||||
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
|
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
|
||||||
// with the junior sheet, then calling CalculateJuniorFee.
|
// with the junior sheet, then calling CalculateJuniorFee.
|
||||||
// Ports scripts/attendance.py get_junior_members_with_fees.
|
// Ports legacy/scripts/attendance.py get_junior_members_with_fees.
|
||||||
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
|
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
|
||||||
if len(adultRows) < 2 || len(juniorRows) < 2 {
|
if len(adultRows) < 2 || len(juniorRows) < 2 {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
@@ -362,7 +362,7 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Payments sheet row parsing (ports scripts/match_payments.py fetch_sheet_data)
|
// Payments sheet row parsing (ports legacy/scripts/match_payments.py fetch_sheet_data)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||||
@@ -466,7 +466,7 @@ func parseFloat(v any) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Exceptions tab parsing (ports scripts/match_payments.py fetch_exceptions)
|
// Exceptions tab parsing (ports legacy/scripts/match_payments.py fetch_exceptions)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
|
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
|
||||||
|
|||||||
@@ -39,4 +39,5 @@ type AdultsResponse struct {
|
|||||||
PaymentsURL string `json:"payments_url"`
|
PaymentsURL string `json:"payments_url"`
|
||||||
BankAccount string `json:"bank_account"`
|
BankAccount string `json:"bank_account"`
|
||||||
CurrentMonth string `json:"current_month"`
|
CurrentMonth string `json:"current_month"`
|
||||||
|
MonthsToShow int `json:"months_to_show"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
type monthSums struct{ expected, paid int }
|
type monthSums struct{ expected, paid int }
|
||||||
|
|
||||||
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
||||||
// Mirrors scripts/views.py:build_adults_view_model.
|
// Mirrors legacy/scripts/views.py:build_adults_view_model.
|
||||||
func buildAdultsResponse(
|
func buildAdultsResponse(
|
||||||
members []domreconcile.Member,
|
members []domreconcile.Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
@@ -138,8 +138,9 @@ func buildAdultsResponse(
|
|||||||
Unmatched: unmatched,
|
Unmatched: unmatched,
|
||||||
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
BankAccount: cfg.BankAccount,
|
BankAccount: cfg.QRAccount,
|
||||||
CurrentMonth: currentMonth,
|
CurrentMonth: currentMonth,
|
||||||
|
MonthsToShow: cfg.MonthsToShow,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// getMonthLabels builds display labels for sortedMonths, merging month names
|
// getMonthLabels builds display labels for sortedMonths, merging month names
|
||||||
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
||||||
// Mirrors scripts/views.py:get_month_labels.
|
// Mirrors legacy/scripts/views.py:get_month_labels.
|
||||||
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
||||||
labels := make(map[string]string, len(sortedMonths))
|
labels := make(map[string]string, len(sortedMonths))
|
||||||
for _, m := range sortedMonths {
|
for _, m := range sortedMonths {
|
||||||
@@ -72,14 +72,14 @@ func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
|
|||||||
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||||
|
|
||||||
// canonicalKey returns a normalized form of a person name used for deduplication.
|
// canonicalKey returns a normalized form of a person name used for deduplication.
|
||||||
// Mirrors scripts/match_payments.py:canonical_member_key.
|
// Mirrors legacy/scripts/match_payments.py:canonical_member_key.
|
||||||
func canonicalKey(name string) string {
|
func canonicalKey(name string) string {
|
||||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
||||||
// canonicalizing names against memberNames where possible.
|
// canonicalizing names against memberNames where possible.
|
||||||
// Mirrors scripts/views.py:group_payments_by_person (without the
|
// Mirrors legacy/scripts/views.py:group_payments_by_person (without the
|
||||||
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
||||||
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
||||||
canonicalByKey := make(map[string]string, len(memberNames))
|
canonicalByKey := make(map[string]string, len(memberNames))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
||||||
// Mirrors scripts/views.py:build_juniors_view_model.
|
// Mirrors legacy/scripts/views.py:build_juniors_view_model.
|
||||||
func buildJuniorsResponse(
|
func buildJuniorsResponse(
|
||||||
members []domreconcile.Member,
|
members []domreconcile.Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
@@ -134,8 +134,9 @@ func buildJuniorsResponse(
|
|||||||
Unmatched: unmatched,
|
Unmatched: unmatched,
|
||||||
AttendanceURL: juniorURL,
|
AttendanceURL: juniorURL,
|
||||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
BankAccount: cfg.BankAccount,
|
BankAccount: cfg.QRAccount,
|
||||||
CurrentMonth: currentMonth,
|
CurrentMonth: currentMonth,
|
||||||
|
MonthsToShow: cfg.MonthsToShow,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
||||||
// Mirrors scripts/views.py:build_payments_view_model.
|
// Mirrors legacy/scripts/views.py:build_payments_view_model.
|
||||||
func buildPaymentsResponse(
|
func buildPaymentsResponse(
|
||||||
txns []domreconcile.Transaction,
|
txns []domreconcile.Transaction,
|
||||||
memberNames []string,
|
memberNames []string,
|
||||||
|
|||||||
@@ -38,4 +38,5 @@ type JuniorsResponse struct {
|
|||||||
PaymentsURL string `json:"payments_url"`
|
PaymentsURL string `json:"payments_url"`
|
||||||
BankAccount string `json:"bank_account"`
|
BankAccount string `json:"bank_account"`
|
||||||
CurrentMonth string `json:"current_month"`
|
CurrentMonth string `json:"current_month"`
|
||||||
|
MonthsToShow int `json:"months_to_show"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// VersionResponse is the JSON contract for GET /api/version.
|
// VersionResponse is the JSON contract for GET /api/version.
|
||||||
// Keys match Python's BUILD_META dict (see app.py).
|
// Keys match Python's BUILD_META dict (see legacy/app.py).
|
||||||
type VersionResponse struct {
|
type VersionResponse struct {
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
|
|||||||
@@ -126,13 +126,13 @@ func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
|
|||||||
amount := q.Get("amount")
|
amount := q.Get("amount")
|
||||||
message := q.Get("message")
|
message := q.Get("message")
|
||||||
if account == "" {
|
if account == "" {
|
||||||
account = h.apiHandler.Config.BankAccount
|
account = h.apiHandler.Config.QRAccount
|
||||||
}
|
}
|
||||||
if amount == "" {
|
if amount == "" {
|
||||||
amount = "0"
|
amount = "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := BuildSPD(account, amount, message, h.apiHandler.Config.BankAccount)
|
payload := BuildSPD(account, amount, message, h.apiHandler.Config.QRAccount)
|
||||||
png, err := RenderQRCode(payload)
|
png, err := RenderQRCode(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func fixtureHandler(t *testing.T) *api.Handler {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
return &api.Handler{
|
return &api.Handler{
|
||||||
Sources: fixtureSources{},
|
Sources: fixtureSources{},
|
||||||
Config: config.Config{BankAccount: "CZ0000000000000000000000"},
|
Config: config.Config{QRAccount: "CZ0000000000000000000000"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
const container = document.getElementById('filterContainer');
|
const container = document.getElementById('filterContainer');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const currentMonth = container.dataset.currentMonth || '';
|
const currentMonth = container.dataset.currentMonth || '';
|
||||||
|
const monthsToShow = parseInt(container.dataset.monthsToShow || '0', 10);
|
||||||
|
|
||||||
const nameInput = document.getElementById('nameFilter');
|
const nameInput = document.getElementById('nameFilter');
|
||||||
const fromSelect = document.getElementById('fromMonth');
|
const fromSelect = document.getElementById('fromMonth');
|
||||||
@@ -88,4 +89,10 @@
|
|||||||
// ── Initialise ────────────────────────────────────────────────────────────
|
// ── Initialise ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
hideFutureMonths();
|
hideFutureMonths();
|
||||||
|
// Default the from-select to show only the last N months.
|
||||||
|
if (monthsToShow > 0 && toSelect.value !== '') {
|
||||||
|
const defaultFrom = Math.max(0, parseInt(toSelect.value, 10) - monthsToShow + 1);
|
||||||
|
fromSelect.value = String(defaultFrom);
|
||||||
|
applyMonthFilter();
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label class="filter-label" for="nameFilter">Member</label>
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label class="filter-label" for="nameFilter">Member</label>
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
|
|||||||
Vendored
+3
-3
@@ -85,10 +85,10 @@ git diff go/tests/fixtures/ # review changes before committing
|
|||||||
To refresh a single function:
|
To refresh a single function:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=scripts:. python3 scripts/capture_fixtures.py --func normalize --all \
|
PYTHONPATH=legacy/scripts:legacy python3 legacy/scripts/capture_fixtures.py --func normalize --all \
|
||||||
| while IFS= read -r line; do
|
| while IFS= read -r line; do
|
||||||
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
|
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
|
||||||
echo "$line" | python3 scripts/scrub_fixtures.py \
|
echo "$line" | python3 legacy/scripts/scrub_fixtures.py \
|
||||||
> go/tests/fixtures/pure/normalize/${id}.json
|
> go/tests/fixtures/pure/normalize/${id}.json
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
@@ -120,7 +120,7 @@ verify that no real roster names appear in their `member_names` arrays.
|
|||||||
|
|
||||||
## Adding a new fixture
|
## Adding a new fixture
|
||||||
|
|
||||||
1. Add a seed to `scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
|
1. Add a seed to `legacy/scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
|
||||||
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
|
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
|
||||||
is new.
|
is new.
|
||||||
3. Run the single-file capture recipe above and review the diff.
|
3. Run the single-file capture recipe above and review the diff.
|
||||||
|
|||||||
+5
-1
@@ -143,6 +143,9 @@
|
|||||||
},
|
},
|
||||||
"current_month": {
|
"current_month": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"months_to_show": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -161,7 +164,8 @@
|
|||||||
"attendance_url",
|
"attendance_url",
|
||||||
"payments_url",
|
"payments_url",
|
||||||
"bank_account",
|
"bank_account",
|
||||||
"current_month"
|
"current_month",
|
||||||
|
"months_to_show"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Credit": {
|
"Credit": {
|
||||||
|
|||||||
+5
-1
@@ -187,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"current_month": {
|
"current_month": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"months_to_show": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -205,7 +208,8 @@
|
|||||||
"attendance_url",
|
"attendance_url",
|
||||||
"payments_url",
|
"payments_url",
|
||||||
"bank_account",
|
"bank_account",
|
||||||
"current_month"
|
"current_month",
|
||||||
|
"months_to_show"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"MemberOtherEntry": {
|
"MemberOtherEntry": {
|
||||||
|
|||||||
+9
-17
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"case": "03_proportional_remainder",
|
"case": "03_proportional_remainder",
|
||||||
"func": "scripts.match_payments.reconcile",
|
"func": "scripts.match_payments.reconcile",
|
||||||
"captured_at": "2026-05-06",
|
"captured_at": "2026-05-11",
|
||||||
"input": {
|
"input": {
|
||||||
"members": [
|
"members": [
|
||||||
{
|
{
|
||||||
@@ -54,10 +54,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 3,
|
"attendance_count": 3,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 324.3243243243243,
|
"paid": 750.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 324.3243243243243,
|
"amount": 750.0,
|
||||||
"date": "2026-03-10",
|
"date": "2026-03-10",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -70,10 +70,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 324.3243243243243,
|
"paid": 50.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 324.3243243243243,
|
"amount": 50.0,
|
||||||
"date": "2026-03-10",
|
"date": "2026-03-10",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -86,25 +86,17 @@
|
|||||||
"original_expected": 350,
|
"original_expected": 350,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 151.35135135135135,
|
"paid": 0,
|
||||||
"transactions": [
|
"transactions": []
|
||||||
{
|
|
||||||
"amount": 151.35135135135135,
|
|
||||||
"date": "2026-03-10",
|
|
||||||
"sender": "Member_d035d9f9",
|
|
||||||
"message": "",
|
|
||||||
"confidence": "auto"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other_transactions": [],
|
"other_transactions": [],
|
||||||
"total_balance": -1051
|
"total_balance": -1050
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"unmatched": [],
|
"unmatched": [],
|
||||||
"credits": {
|
"credits": {
|
||||||
"Member_d035d9f9": -1051
|
"Member_d035d9f9": -1050
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"case": "09_multiperson_multimonth",
|
"case": "09_multiperson_multimonth",
|
||||||
"func": "scripts.match_payments.reconcile",
|
"func": "scripts.match_payments.reconcile",
|
||||||
"captured_at": "2026-05-06",
|
"captured_at": "2026-05-11",
|
||||||
"input": {
|
"input": {
|
||||||
"members": [
|
"members": [
|
||||||
{
|
{
|
||||||
@@ -63,10 +63,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 3,
|
"attendance_count": 3,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 500.0,
|
"paid": 750.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 500.0,
|
"amount": 750.0,
|
||||||
"date": "2026-02-15",
|
"date": "2026-02-15",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -79,10 +79,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 500.0,
|
"paid": 250.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 500.0,
|
"amount": 250.0,
|
||||||
"date": "2026-02-15",
|
"date": "2026-02-15",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -102,10 +102,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 681.8181818181819,
|
"paid": 750.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 681.8181818181819,
|
"amount": 750.0,
|
||||||
"date": "2026-02-15",
|
"date": "2026-02-15",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -118,10 +118,10 @@
|
|||||||
"original_expected": 350,
|
"original_expected": 350,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 318.18181818181813,
|
"paid": 250.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 318.18181818181813,
|
"amount": 250.0,
|
||||||
"date": "2026-02-15",
|
"date": "2026-02-15",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -131,13 +131,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other_transactions": [],
|
"other_transactions": [],
|
||||||
"total_balance": -101
|
"total_balance": -100
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"unmatched": [],
|
"unmatched": [],
|
||||||
"credits": {
|
"credits": {
|
||||||
"Member_d035d9f9": -500,
|
"Member_d035d9f9": -500,
|
||||||
"Member_f4a93e46": -101
|
"Member_f4a93e46": -100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
|
||||||
|
|
||||||
func TestBuildNameVariantsParity(t *testing.T) {
|
func TestBuildNameVariantsParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
|
||||||
|
|
||||||
func TestCalculateFeeParity(t *testing.T) {
|
func TestCalculateFeeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
|
||||||
|
|
||||||
func TestCalculateJuniorFeeParity(t *testing.T) {
|
func TestCalculateJuniorFeeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import format_date; print(format_date(46027))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import format_date; print(format_date(46027))"
|
||||||
|
|
||||||
func TestFormatDateParity(t *testing.T) {
|
func TestFormatDateParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "
|
||||||
// from sync_fio_to_sheets import generate_sync_id
|
// from sync_fio_to_sheets import generate_sync_id
|
||||||
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
|
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
|
||||||
//
|
//
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "
|
||||||
// from match_payments import infer_transaction_details
|
// from match_payments import infer_transaction_details
|
||||||
// print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))"
|
// print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
|
||||||
|
|
||||||
func TestMatchMembersParity(t *testing.T) {
|
func TestMatchMembersParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
|
||||||
|
|
||||||
func TestNormalizeParity(t *testing.T) {
|
func TestNormalizeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
|
||||||
//
|
//
|
||||||
// Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0.
|
// Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0.
|
||||||
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
|
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
|
||||||
|
|
||||||
func TestParseMonthReferencesParity(t *testing.T) {
|
func TestParseMonthReferencesParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//
|
//
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "
|
||||||
// from match_payments import reconcile
|
// from match_payments import reconcile
|
||||||
// # ... build members/months/txs/exceptions...
|
// # ... build members/months/txs/exceptions...
|
||||||
// import json; print(json.dumps(reconcile(...)))"
|
// import json; print(json.dumps(reconcile(...)))"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.git
|
.git
|
||||||
.venv
|
.venv
|
||||||
__pycache__
|
**/__pycache__
|
||||||
*.pyc
|
**/*.pyc
|
||||||
.claude
|
.claude
|
||||||
.gemini
|
.gemini
|
||||||
@@ -19,7 +19,7 @@ sys.path.append(str(scripts_dir))
|
|||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
||||||
BANK_ACCOUNT, CREDENTIALS_PATH,
|
BANK_ACCOUNT, CREDENTIALS_PATH, MONTHS_TO_SHOW,
|
||||||
)
|
)
|
||||||
from attendance import get_members_with_fees, get_junior_members_with_fees
|
from attendance import get_members_with_fees, get_junior_members_with_fees
|
||||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
|
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
|
||||||
@@ -255,7 +255,7 @@ def adults_view():
|
|||||||
bank_account=BANK_ACCOUNT,
|
bank_account=BANK_ACCOUNT,
|
||||||
)
|
)
|
||||||
record_step("process_data")
|
record_step("process_data")
|
||||||
return render_template("adults.html", **vm)
|
return render_template("adults.html", months_to_show=MONTHS_TO_SHOW, **vm)
|
||||||
|
|
||||||
@app.route("/juniors")
|
@app.route("/juniors")
|
||||||
def juniors_view():
|
def juniors_view():
|
||||||
@@ -291,7 +291,7 @@ def juniors_view():
|
|||||||
bank_account=BANK_ACCOUNT,
|
bank_account=BANK_ACCOUNT,
|
||||||
)
|
)
|
||||||
record_step("process_data")
|
record_step("process_data")
|
||||||
return render_template("juniors.html", **vm)
|
return render_template("juniors.html", months_to_show=MONTHS_TO_SHOW, **vm)
|
||||||
|
|
||||||
@app.route("/payments")
|
@app.route("/payments")
|
||||||
def payments():
|
def payments():
|
||||||
@@ -17,7 +17,7 @@ RUN pip install --no-cache-dir \
|
|||||||
pillow \
|
pillow \
|
||||||
gunicorn
|
gunicorn
|
||||||
|
|
||||||
COPY app.py Makefile ./
|
COPY app.py ./
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fuj-management"
|
name = "fuj-management"
|
||||||
version = "0.10"
|
version = "0.10"
|
||||||
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
|
description = "Legacy Python implementation of the FUJ (Frisbee Ultimate Jablonec) management tools; the Go implementation in ../go is primary"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.1.3",
|
"flask>=3.1.3",
|
||||||
"google-api-python-client>=2.162.0",
|
"google-api-python-client>=2.162.0",
|
||||||
@@ -21,7 +21,10 @@ ADULT_FEE_MONTHLY_RATE = {
|
|||||||
"2026-02": 750,
|
"2026-02": 750,
|
||||||
"2026-03": 350,
|
"2026-03": 350,
|
||||||
"2026-04": 700,
|
"2026-04": 700,
|
||||||
"2026-05": 700,
|
"2026-05": 450,
|
||||||
|
"2026-06": 600,
|
||||||
|
"2026-07": 600,
|
||||||
|
"2026-08": 600,
|
||||||
}
|
}
|
||||||
|
|
||||||
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regenerate the full fixture corpus.
|
# Regenerate the full fixture corpus.
|
||||||
# Safe to re-run — always overwrites.
|
# Safe to re-run — always overwrites.
|
||||||
# Requires: tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
|
# Requires: legacy/tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
LEGACY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REPO="$(cd "$LEGACY/.." && pwd)"
|
||||||
FIXTURES="$REPO/go/tests/fixtures"
|
FIXTURES="$REPO/go/tests/fixtures"
|
||||||
CAPTURE_CMD="PYTHONPATH=$REPO/scripts:. python3 $REPO/scripts/capture_fixtures.py"
|
CAPTURE_CMD="PYTHONPATH=$LEGACY/scripts:$LEGACY python3 $LEGACY/scripts/capture_fixtures.py"
|
||||||
SCRUB_CMD="python3 $REPO/scripts/scrub_fixtures.py"
|
SCRUB_CMD="python3 $LEGACY/scripts/scrub_fixtures.py"
|
||||||
|
|
||||||
run_func() {
|
run_func() {
|
||||||
local func="$1"
|
local func="$1"
|
||||||
@@ -18,7 +19,7 @@ run_func() {
|
|||||||
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
|
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
|
||||||
case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \
|
case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \
|
||||||
echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
||||||
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
|
echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ echo "==> Capturing reconcile fixtures..."
|
|||||||
mkdir -p "$FIXTURES/reconcile"
|
mkdir -p "$FIXTURES/reconcile"
|
||||||
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
|
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
|
||||||
case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
||||||
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
|
echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "==> Done. Review with: git diff go/tests/fixtures/"
|
echo "==> Done. Review with: git diff go/tests/fixtures/"
|
||||||
@@ -21,8 +21,18 @@ PAYMENTS_SHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
|||||||
# Attendance sheet tab GIDs
|
# Attendance sheet tab GIDs
|
||||||
JUNIOR_SHEET_GID = "1213318614"
|
JUNIOR_SHEET_GID = "1213318614"
|
||||||
|
|
||||||
# Bank
|
# Bank accounts — hardcoded list mirroring go/internal/config/config.go.
|
||||||
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
|
# The entry with primary=True is used for QR codes and as the BANK_ACCOUNT default.
|
||||||
|
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},
|
||||||
|
]
|
||||||
|
# Resolve API tokens from the environment once at import time.
|
||||||
|
LOADED_ACCOUNTS = [
|
||||||
|
{**a, "token": os.environ.get(a["token_env"], "")}
|
||||||
|
for a in ACCOUNTS
|
||||||
|
]
|
||||||
|
BANK_ACCOUNT = next(a["iban"] for a in ACCOUNTS if a["primary"])
|
||||||
|
|
||||||
# Cache settings
|
# Cache settings
|
||||||
CACHE_DIR = PROJECT_ROOT / "tmp"
|
CACHE_DIR = PROJECT_ROOT / "tmp"
|
||||||
@@ -30,6 +40,9 @@ DRIVE_TIMEOUT = 10 # seconds
|
|||||||
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
|
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
|
||||||
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
|
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
|
||||||
|
|
||||||
|
# Display settings
|
||||||
|
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5)) # show last N months; 0 = show all
|
||||||
|
|
||||||
# Maps cache keys to their source sheet IDs (used by cache_utils)
|
# Maps cache keys to their source sheet IDs (used by cache_utils)
|
||||||
CACHE_SHEET_MAP = {
|
CACHE_SHEET_MAP = {
|
||||||
"attendance_regular": ATTENDANCE_SHEET_ID,
|
"attendance_regular": ATTENDANCE_SHEET_ID,
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
@@ -89,9 +90,11 @@ def parse_czech_amount(s: str) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def parse_czech_date(s: str) -> str | None:
|
def parse_czech_date(s: str) -> str | None:
|
||||||
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'."""
|
"""Parse a Czech date to 'YYYY-MM-DD'. Accepts 4-digit and 2-digit years
|
||||||
|
with dot or slash separators; Fio's transparent page mixes 'DD.MM.YYYY'
|
||||||
|
and 'DD.MM.YY' in the same response."""
|
||||||
s = s.strip()
|
s = s.strip()
|
||||||
for fmt in ("%d.%m.%Y", "%d/%m/%Y"):
|
for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%d.%m.%y", "%d/%m/%y"):
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -100,7 +103,7 @@ def parse_czech_date(s: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_transactions_transparent(
|
def fetch_transactions_transparent(
|
||||||
date_from: str, date_to: str, account_id: str = "2800359168"
|
date_from: str, date_to: str, account_id: str
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Fetch transactions from Fio transparent account HTML page.
|
"""Fetch transactions from Fio transparent account HTML page.
|
||||||
|
|
||||||
@@ -146,6 +149,7 @@ def fetch_transactions_transparent(
|
|||||||
"bank_id": "", # HTML scraping doesn't give stable ID
|
"bank_id": "", # HTML scraping doesn't give stable ID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
print(f"fio: transparent fetched {len(rows)} raw rows, {len(transactions)} transaction(s) after filtering", file=sys.stderr)
|
||||||
return transactions
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
@@ -169,7 +173,8 @@ def fetch_transactions_api(
|
|||||||
|
|
||||||
transactions = []
|
transactions = []
|
||||||
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
||||||
for tx in (tx_list.get("transaction") or []):
|
raw_list = tx_list.get("transaction") or []
|
||||||
|
for tx in raw_list:
|
||||||
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
||||||
def val(col_id):
|
def val(col_id):
|
||||||
col = tx.get(f"column{col_id}")
|
col = tx.get(f"column{col_id}")
|
||||||
@@ -197,19 +202,51 @@ def fetch_transactions_api(
|
|||||||
"currency": str(val(14) or "CZK"), # column14 = Currency
|
"currency": str(val(14) or "CZK"), # column14 = Currency
|
||||||
})
|
})
|
||||||
|
|
||||||
|
print(f"fio: api fetched {len(raw_list)} raw transaction(s), {len(transactions)} after filtering", file=sys.stderr)
|
||||||
return transactions
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
|
def fetch_transactions_for(account: dict, date_from: str, date_to: str) -> list[dict]:
|
||||||
"""Fetch transactions, using API if token available, else transparent page."""
|
"""Fetch transactions for a single loaded account dict (from config.LOADED_ACCOUNTS).
|
||||||
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
|
||||||
|
Uses the API path if the account has a token, otherwise the transparent scraper.
|
||||||
|
date_from/date_to: YYYY-MM-DD.
|
||||||
|
"""
|
||||||
|
token = (account.get("token") or "").strip()
|
||||||
|
acct_num = account["acct_num"]
|
||||||
if token:
|
if token:
|
||||||
|
print(f"fio: account {acct_num}: using authenticated API, window {date_from}..{date_to}", file=sys.stderr)
|
||||||
return fetch_transactions_api(token, date_from, date_to)
|
return fetch_transactions_api(token, date_from, date_to)
|
||||||
|
|
||||||
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
|
print(
|
||||||
|
f"fio: account {acct_num}: using transparent page (no token — expect publishing lag), "
|
||||||
|
f"window {date_from}..{date_to}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
||||||
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
||||||
return fetch_transactions_transparent(
|
return fetch_transactions_transparent(
|
||||||
from_dt.strftime("%d.%m.%Y"),
|
from_dt.strftime("%d.%m.%Y"),
|
||||||
to_dt.strftime("%d.%m.%Y"),
|
to_dt.strftime("%d.%m.%Y"),
|
||||||
|
account_id=acct_num,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_transactions_all(
|
||||||
|
date_from: str, date_to: str, accounts: list[dict] | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch and combine transactions from all configured accounts.
|
||||||
|
|
||||||
|
accounts: list of loaded account dicts (defaults to config.LOADED_ACCOUNTS).
|
||||||
|
Returns a flat list of all transactions across all accounts.
|
||||||
|
"""
|
||||||
|
if accounts is None:
|
||||||
|
from config import LOADED_ACCOUNTS
|
||||||
|
accounts = LOADED_ACCOUNTS
|
||||||
|
all_txns: list[dict] = []
|
||||||
|
for account in accounts:
|
||||||
|
txns = fetch_transactions_for(account, date_from, date_to)
|
||||||
|
print(f"fio: account {account['acct_num']}: {len(txns)} transaction(s)", file=sys.stderr)
|
||||||
|
all_txns.extend(txns)
|
||||||
|
print(f"fio: total {len(all_txns)} transaction(s) across {len(accounts)} account(s)", file=sys.stderr)
|
||||||
|
return all_txns
|
||||||
@@ -26,6 +26,15 @@ def canonical_member_key(name: str) -> str:
|
|||||||
return re.sub(r"\s+", " ", normalize(name)).strip()
|
return re.sub(r"\s+", " ", normalize(name)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_amount(value) -> float:
|
||||||
|
"""Numeric value of an expected fee; non-numeric markers like '?' → 0.
|
||||||
|
|
||||||
|
Juniors with exactly 1 session get expected='?' (manual-review marker).
|
||||||
|
Treat those as 0 for arithmetic so payments become surplus/credit.
|
||||||
|
"""
|
||||||
|
return value if isinstance(value, (int, float)) else 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Name matching
|
# Name matching
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -466,28 +475,21 @@ def reconcile(
|
|||||||
if not in_window:
|
if not in_window:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
total_expected = sum(e for _, e in in_window)
|
total_expected = sum(_expected_amount(e) for _, e in in_window)
|
||||||
|
|
||||||
if total_expected > 0 and in_window_share >= total_expected:
|
if total_expected > 0:
|
||||||
# Greedy phase: payment covers all in-window fees; overflow → credit.
|
# Fill-first: iterate in_window in matched_months order (chronological by
|
||||||
credits[member_name] = credits.get(member_name, 0) + int(in_window_share - total_expected)
|
# convention from infer_payments.py), allocating min(remaining, deficit) to
|
||||||
for m, exp in in_window:
|
# each month. Deficit is net of what prior transactions already paid, so a
|
||||||
alloc = float(exp)
|
# second payment on the same months correctly fills only what remains due.
|
||||||
ledger[member_name][m]["paid"] += alloc
|
# Any surplus after all deficits are covered goes to the credit bucket.
|
||||||
ledger[member_name][m]["transactions"].append({
|
|
||||||
"amount": alloc,
|
|
||||||
"date": tx["date"],
|
|
||||||
"sender": tx["sender"],
|
|
||||||
"message": tx["message"],
|
|
||||||
"confidence": confidence,
|
|
||||||
})
|
|
||||||
elif total_expected > 0:
|
|
||||||
# Proportional phase: distribute in_window_share by each month's expected fee.
|
|
||||||
# Last month absorbs any float remainder so the sum equals in_window_share exactly.
|
|
||||||
remaining = in_window_share
|
remaining = in_window_share
|
||||||
for i, (m, exp) in enumerate(in_window):
|
for m, exp in in_window:
|
||||||
alloc = remaining if i == len(in_window) - 1 else in_window_share * exp / total_expected
|
paid_so_far = ledger[member_name][m]["paid"]
|
||||||
remaining -= alloc
|
deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)
|
||||||
|
alloc = min(remaining, deficit)
|
||||||
|
if alloc <= 0:
|
||||||
|
continue
|
||||||
ledger[member_name][m]["paid"] += alloc
|
ledger[member_name][m]["paid"] += alloc
|
||||||
ledger[member_name][m]["transactions"].append({
|
ledger[member_name][m]["transactions"].append({
|
||||||
"amount": alloc,
|
"amount": alloc,
|
||||||
@@ -496,6 +498,9 @@ def reconcile(
|
|||||||
"message": tx["message"],
|
"message": tx["message"],
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
})
|
})
|
||||||
|
remaining -= alloc
|
||||||
|
if remaining > 0:
|
||||||
|
credits[member_name] = credits.get(member_name, 0) + int(remaining)
|
||||||
else:
|
else:
|
||||||
# Fallback: no expected fees (prepayment before attendance recorded); even split.
|
# Fallback: no expected fees (prepayment before attendance recorded); even split.
|
||||||
per_month = in_window_share / len(in_window)
|
per_month = in_window_share / len(in_window)
|
||||||
@@ -513,7 +518,7 @@ def reconcile(
|
|||||||
final_balances: dict[str, int] = {}
|
final_balances: dict[str, int] = {}
|
||||||
for name in member_names:
|
for name in member_names:
|
||||||
window_balance = sum(
|
window_balance = sum(
|
||||||
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
|
int(mdata["paid"]) - _expected_amount(mdata["expected"])
|
||||||
for mdata in ledger[name].values()
|
for mdata in ledger[name].values()
|
||||||
)
|
)
|
||||||
final_balances[name] = window_balance + credits.get(name, 0)
|
final_balances[name] = window_balance + credits.get(name, 0)
|
||||||
@@ -12,7 +12,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow
|
|||||||
from google.oauth2 import service_account
|
from google.oauth2 import service_account
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
from fio_utils import fetch_transactions
|
from fio_utils import fetch_transactions_all
|
||||||
|
|
||||||
from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
|
from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
|
||||||
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
||||||
@@ -77,6 +77,35 @@ def generate_sync_id(tx: dict) -> str:
|
|||||||
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _trunc(s: str, n: int = 40) -> str:
|
||||||
|
s = str(s)
|
||||||
|
return s if len(s) <= n else s[: n - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_fio_table(transactions: list[dict], statuses: list[str]) -> None:
|
||||||
|
headers = ["DATE", "AMOUNT", "SENDER", "VS", "MESSAGE", "BANKID", "STATUS"]
|
||||||
|
rows = [
|
||||||
|
[
|
||||||
|
str(tx.get("date", "")),
|
||||||
|
f"{float(tx.get('amount', 0)):.2f}",
|
||||||
|
str(tx.get("sender", "")),
|
||||||
|
str(tx.get("vs", "")),
|
||||||
|
_trunc(str(tx.get("message", ""))),
|
||||||
|
str(tx.get("bank_id", "")),
|
||||||
|
status,
|
||||||
|
]
|
||||||
|
for tx, status in zip(transactions, statuses)
|
||||||
|
]
|
||||||
|
widths = [
|
||||||
|
max(len(headers[i]), max((len(r[i]) for r in rows), default=0))
|
||||||
|
for i in range(len(headers))
|
||||||
|
]
|
||||||
|
sep = " "
|
||||||
|
print(sep.join(h.ljust(w) for h, w in zip(headers, widths)))
|
||||||
|
for row in rows:
|
||||||
|
print(sep.join(cell.ljust(w) for cell, w in zip(row, widths)))
|
||||||
|
|
||||||
|
|
||||||
def sort_sheet_by_date(service, spreadsheet_id):
|
def sort_sheet_by_date(service, spreadsheet_id):
|
||||||
"""Sort the sheet by the Date column (Column B)."""
|
"""Sort the sheet by the Date column (Column B)."""
|
||||||
# Get the sheet ID (gid) of the first sheet
|
# Get the sheet ID (gid) of the first sheet
|
||||||
@@ -104,12 +133,21 @@ def sort_sheet_by_date(service, spreadsheet_id):
|
|||||||
print("Sheet sorted by date.")
|
print("Sheet sorted by date.")
|
||||||
|
|
||||||
|
|
||||||
def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None, date_from_str: str = None, date_to_str: str = None, sort_by_date: bool = False):
|
def sync_to_sheets(
|
||||||
|
spreadsheet_id: str,
|
||||||
|
credentials_path: str,
|
||||||
|
days: int = None,
|
||||||
|
date_from_str: str = None,
|
||||||
|
date_to_str: str = None,
|
||||||
|
sort_by_date: bool = False,
|
||||||
|
dry_run: bool = False,
|
||||||
|
print_fio_table: bool = False,
|
||||||
|
):
|
||||||
print(f"Connecting to Google Sheets using {credentials_path}...")
|
print(f"Connecting to Google Sheets using {credentials_path}...")
|
||||||
service = get_sheets_service(credentials_path)
|
service = get_sheets_service(credentials_path)
|
||||||
sheet = service.spreadsheets()
|
sheet = service.spreadsheets()
|
||||||
|
|
||||||
# 1. Fetch existing IDs from Column G (last column in A-G range)
|
# 1. Read existing sync IDs from Column K
|
||||||
print(f"Reading existing sync IDs from sheet...")
|
print(f"Reading existing sync IDs from sheet...")
|
||||||
try:
|
try:
|
||||||
result = sheet.values().get(
|
result = sheet.values().get(
|
||||||
@@ -117,19 +155,22 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
range="A1:K" # Include header and all columns to check Sync ID
|
range="A1:K" # Include header and all columns to check Sync ID
|
||||||
).execute()
|
).execute()
|
||||||
values = result.get("values", [])
|
values = result.get("values", [])
|
||||||
|
|
||||||
# Check and insert labels if missing
|
# Check and insert labels if missing
|
||||||
if not values or values[0] != COLUMN_LABELS:
|
if not values or values[0] != COLUMN_LABELS:
|
||||||
print("Inserting column labels...")
|
if dry_run:
|
||||||
sheet.values().update(
|
print("Dry run: would write header row")
|
||||||
spreadsheetId=spreadsheet_id,
|
else:
|
||||||
range="A1",
|
print("Inserting column labels...")
|
||||||
valueInputOption="USER_ENTERED",
|
sheet.values().update(
|
||||||
body={"values": [COLUMN_LABELS]}
|
spreadsheetId=spreadsheet_id,
|
||||||
).execute()
|
range="A1",
|
||||||
|
valueInputOption="USER_ENTERED",
|
||||||
|
body={"values": [COLUMN_LABELS]}
|
||||||
|
).execute()
|
||||||
existing_ids = set()
|
existing_ids = set()
|
||||||
else:
|
else:
|
||||||
# Sync ID is now the last column (index 10)
|
# Sync ID is the last column (index 10)
|
||||||
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
|
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading sheet (maybe empty?): {e}")
|
print(f"Error reading sheet (maybe empty?): {e}")
|
||||||
@@ -147,11 +188,15 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
dt_str = date_to.strftime("%Y-%m-%d")
|
dt_str = date_to.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
print(f"Fetching Fio transactions from {df_str} to {dt_str}...")
|
print(f"Fetching Fio transactions from {df_str} to {dt_str}...")
|
||||||
transactions = fetch_transactions(df_str, dt_str)
|
transactions = fetch_transactions_all(df_str, dt_str)
|
||||||
print(f"Found {len(transactions)} transactions.")
|
print(f"Found {len(transactions)} transactions.")
|
||||||
|
|
||||||
# 3. Filter for new transactions
|
if dry_run:
|
||||||
|
print(f"Dry run: window {df_str} to {dt_str}, fetched {len(transactions)} transaction(s) from Fio")
|
||||||
|
|
||||||
|
# 3. Determine NEW/DUP for each transaction
|
||||||
new_rows = []
|
new_rows = []
|
||||||
|
tx_statuses = []
|
||||||
for tx in transactions:
|
for tx in transactions:
|
||||||
sync_id = generate_sync_id(tx)
|
sync_id = generate_sync_id(tx)
|
||||||
if sync_id not in existing_ids:
|
if sync_id not in existing_ids:
|
||||||
@@ -169,24 +214,48 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
tx.get("bank_id", ""),
|
tx.get("bank_id", ""),
|
||||||
sync_id,
|
sync_id,
|
||||||
])
|
])
|
||||||
|
tx_statuses.append("NEW")
|
||||||
|
else:
|
||||||
|
tx_statuses.append("DUP")
|
||||||
|
|
||||||
|
# 4. Print table (before early-return so all transactions are shown including DUPs)
|
||||||
|
if print_fio_table and transactions:
|
||||||
|
_print_fio_table(transactions, tx_statuses)
|
||||||
|
|
||||||
if not new_rows:
|
if not new_rows:
|
||||||
print("No new transactions to sync.")
|
if dry_run:
|
||||||
|
print("Dry run: would sync 0 new transaction(s).")
|
||||||
|
else:
|
||||||
|
print("No new transactions to sync.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 4. Append to sheet
|
# 5. Append to sheet or print dry-run would-write lines
|
||||||
print(f"Appending {len(new_rows)} new transactions to the sheet...")
|
if dry_run:
|
||||||
body = {"values": new_rows}
|
for tx, status in zip(transactions, tx_statuses):
|
||||||
sheet.values().append(
|
if status == "NEW":
|
||||||
spreadsheetId=spreadsheet_id,
|
print(
|
||||||
range="A2", # Appends to the end of the sheet
|
f"Dry run: would append"
|
||||||
valueInputOption="USER_ENTERED",
|
f" date={tx.get('date', '')}"
|
||||||
body=body
|
f" amount={tx.get('amount', '')}"
|
||||||
).execute()
|
f" sender={tx.get('sender', '')}"
|
||||||
print("Sync completed successfully.")
|
f" vs={tx.get('vs', '')}"
|
||||||
|
f" message={tx.get('message', '')}"
|
||||||
if sort_by_date:
|
)
|
||||||
sort_sheet_by_date(service, spreadsheet_id)
|
if sort_by_date:
|
||||||
|
print("Dry run: would sort by date")
|
||||||
|
print(f"Dry run: would sync {len(new_rows)} new transaction(s).")
|
||||||
|
else:
|
||||||
|
print(f"Appending {len(new_rows)} new transactions to the sheet...")
|
||||||
|
body = {"values": new_rows}
|
||||||
|
sheet.values().append(
|
||||||
|
spreadsheetId=spreadsheet_id,
|
||||||
|
range="A2", # Appends to the end of the sheet
|
||||||
|
valueInputOption="USER_ENTERED",
|
||||||
|
body=body
|
||||||
|
).execute()
|
||||||
|
print("Sync completed successfully.")
|
||||||
|
if sort_by_date:
|
||||||
|
sort_sheet_by_date(service, spreadsheet_id)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -197,16 +266,20 @@ def main():
|
|||||||
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
|
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
|
||||||
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
|
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
|
||||||
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
|
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Fetch and dedup without writing to the sheet")
|
||||||
|
parser.add_argument("--print-fio-table", action="store_true", help="Print aligned table of all fetched transactions with NEW/DUP status (use with --dry-run)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sync_to_sheets(
|
sync_to_sheets(
|
||||||
spreadsheet_id=args.sheet_id,
|
spreadsheet_id=args.sheet_id,
|
||||||
credentials_path=args.credentials,
|
credentials_path=args.credentials,
|
||||||
days=args.days,
|
days=args.days,
|
||||||
date_from_str=args.date_from,
|
date_from_str=args.date_from,
|
||||||
date_to_str=args.date_to,
|
date_to_str=args.date_to,
|
||||||
sort_by_date=args.sort_by_date
|
sort_by_date=args.sort_by_date,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
print_fio_table=args.print_fio_table,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Sync failed: {e}")
|
print(f"Sync failed: {e}")
|
||||||
@@ -1045,6 +1045,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
|
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -1026,6 +1026,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
|
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user