Compare commits

..

1 Commits

Author SHA1 Message Date
dd629fa2a9 gitignore go/parity
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
2026-05-12 00:08:38 +02:00
36 changed files with 72 additions and 1297 deletions

View File

@@ -10,34 +10,7 @@
"Bash(./bin/fuj help *)",
"Bash(./bin/fuj version *)",
"Bash(make go-test *)",
"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"
"Bash(make go-lint *)"
]
}
}

View File

@@ -1,94 +0,0 @@
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
run: |
apt-get update -qq
apt-get install -y --no-install-recommends git curl ca-certificates
- 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
GIT_TAG="${{ inputs.tag }}"
else
# workflow_run: use the ref name of the triggering workflow (the pushed git tag).
GIT_TAG="${{ github.event.workflow_run.head_branch }}"
fi
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "Resolved image: ${IMAGE}"
- name: Configure git identity
run: |
git config --global user.name "uh-cli bot"
git config --global user.email "bot@hrajfrisbee.cz"
- 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: |
uh-cli -v gitops deployment update \
--deployment-name fuj-management \
--deployment-namespace fuj \
--set-image "${{ steps.resolve.outputs.image }}" \
--git-repo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes \
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}

View File

@@ -1,20 +1,5 @@
# Changelog
## 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
@@ -29,12 +14,6 @@
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
## 2026-05-08 15:24 CEST — feat(go): M6.7 — single-binary embed verification
- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
- Added `go/internal/web/assets_test.go` with two tests: `TestEmbedCompleteness` (walks disk vs embed.FS to catch forgotten files) and `TestStaticAssetsServed` (hits `/static/css/app.css` and all JS files through the mux, asserts 200 + Content-Type + non-empty body + 404 for unknown paths).
- Closes M6; single binary confirmed self-contained with no adjacent `templates/` or `static/` required at runtime.
- Key files: `go/internal/web/assets_test.go` (new).
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal

6
app.py
View File

@@ -19,7 +19,7 @@ sys.path.append(str(scripts_dir))
from config import (
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
BANK_ACCOUNT, CREDENTIALS_PATH, MONTHS_TO_SHOW,
BANK_ACCOUNT, CREDENTIALS_PATH,
)
from attendance import get_members_with_fees, get_junior_members_with_fees
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
@@ -255,7 +255,7 @@ def adults_view():
bank_account=BANK_ACCOUNT,
)
record_step("process_data")
return render_template("adults.html", months_to_show=MONTHS_TO_SHOW, **vm)
return render_template("adults.html", **vm)
@app.route("/juniors")
def juniors_view():
@@ -291,7 +291,7 @@ def juniors_view():
bank_account=BANK_ACCOUNT,
)
record_step("process_data")
return render_template("juniors.html", months_to_show=MONTHS_TO_SHOW, **vm)
return render_template("juniors.html", **vm)
@app.route("/payments")
def payments():

View File

@@ -117,7 +117,7 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
- [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238`
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
- [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets — (pending merge)
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.

View File

@@ -1,184 +0,0 @@
# M6.7 — Single-binary embed verification
## Context
M6.7 is the final task in M6 (Go-native HTML frontend). Per the
[progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md): "Wire
`embed.FS` into handlers; verify single-binary deployment includes all
assets."
The wiring is **already in place** from M6.1 onward:
- [go/internal/web/assets.go](../../go/internal/web/assets.go) declares
`//go:embed templates``templateFS` and `//go:embed static``staticFS`.
- [go/internal/web/render.go:66](../../go/internal/web/render.go#L66)
parses every page template via `template.New(...).ParseFS(templateFS, ...)`.
- [go/internal/web/server.go:48-68](../../go/internal/web/server.go#L48-L68)
serves `/static/*` via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
- [go/build/Dockerfile](../../go/build/Dockerfile) copies only the compiled
binary into the `alpine:3` runtime image — no `templates/` or `static/`
directory ever lands beside it.
What is missing is **proof** the embed is complete and stays complete:
1. Nothing fails the build/test if a contributor adds a new file under
`internal/web/templates/` or `internal/web/static/` that isn't matched
by an `//go:embed` glob (or, more realistically, adds a sibling
directory like `static/img/` and the glob still picks it up — but a
typo'd directive would silently drop it).
2. No automated test exercises the `/static/*` route against the embedded
FS — current tests in
[html_handler_test.go](../../go/internal/web/html_handler_test.go)
render templates (which proves `templateFS` is good) but never hit a
static URL through the mux.
3. The "single binary, no working-dir assets" property is undocumented —
if it ever broke, no one would notice until the Docker image started
500'ing in prod.
The intended outcome: a small test file plus a documented manual
verification step, after which M6.7 can be ticked and M6 closed.
## Plan
### 1. Add `go/internal/web/assets_test.go`
One new file, two tests, no production code changes.
**Test A — embed completeness regression guard.** Walks the on-disk
`templates/` and `static/` directories and asserts every regular file is
also present in the corresponding embedded FS. Catches:
- A new template added without updating the `//go:embed` directive
(current globs are `templates` and `static` — recursive by default for
directories, so this is a low-probability regression, but the test
doubles as living documentation of the contract).
- A typo in the directive (e.g. someone renames `static``assets` in
one place but not the other).
Implementation sketch:
```go
func TestEmbedCompleteness(t *testing.T) {
cases := []struct {
name string
diskFS fs.FS // os.DirFS("templates") / os.DirFS("static")
embed fs.FS // exported helper or via internal test in package web
root string
}{...}
for _, tc := range cases {
_ = fs.WalkDir(tc.diskFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { return err }
embPath := tc.root + "/" + path
if _, err := fs.Stat(tc.embed, embPath); err != nil {
t.Errorf("file %q on disk but missing in embed.FS: %v", embPath, err)
}
return nil
})
}
}
```
Because `templateFS` and `staticFS` are unexported package vars, this
test lives in `package web` (not `web_test`) — sibling to
[assets.go](../../go/internal/web/assets.go). All the existing handler
tests are in `package web_test`; that's fine, this one is internal.
**Test B — `/static/*` end-to-end via the mux.** Builds an `http.ServeMux`
with the same wiring as
[server.go:68](../../go/internal/web/server.go#L68), fires httptest
requests, asserts:
- `GET /static/css/app.css` → 200, `Content-Type: text/css; charset=utf-8`,
body contains a known string from app.css (e.g. a CSS selector).
- `GET /static/js/member-detail.js` → 200, `Content-Type` starts with
`text/javascript` or `application/javascript`, body non-empty.
- `GET /static/js/payment-qr.js` → 200, body non-empty.
- `GET /static/css/missing.css` → 404 (sanity: the file server actually
rejects unknown paths instead of returning some default).
Rather than duplicate the mux assembly, factor a tiny helper (or test the
existing mux). The cleanest move: extract `staticHandler()` from
[server.go:48-50,68](../../go/internal/web/server.go#L48-L68) into a small
exported-from-package function or just `staticFS` / `fs.Sub` helper, and
have the test call it. Smallest delta: keep production code unchanged and
replicate the two-line wiring inside the test file (acceptable — it's
two lines and the test exists precisely to lock that contract).
### 2. Manual / one-shot verification (no code; documented in plan only)
Run once locally and tick M6.7. Command transcript:
```bash
make go-build # → ./bin/fuj
cp bin/fuj /tmp/fuj-standalone
cd /tmp # working dir has no templates/ or static/
./fuj-standalone server &
SERVER_PID=$!
sleep 1
curl -sf http://localhost:8080/adults | grep -q "Adults Dashboard"
curl -sf http://localhost:8080/juniors | grep -q "Juniors"
curl -sf http://localhost:8080/payments | grep -q "Payments Ledger"
curl -sf -o /tmp/app.css http://localhost:8080/static/css/app.css \
&& test -s /tmp/app.css
curl -sf -o /tmp/qr.js http://localhost:8080/static/js/payment-qr.js \
&& test -s /tmp/qr.js
kill $SERVER_PID
```
`fuj server` will fail to talk to Sheets without credentials, so the
`/adults` etc. pages will render with the `Error` field set — that's
fine; the assertion is that the **template + static asset pipeline** is
self-contained, not that data loads. Each curl above only checks for
markup present in every render path (header text and stylesheet body).
### 3. Tracker + changelog
- Tick `M6.7` in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120](2026-05-03-2349-go-backend-rewrite-progress.md#L120),
append the merge SHA on the line.
- Mark "Last updated" date and bump milestone status: M6 complete, next is
M7.
- Append a `CHANGELOG.md` entry per CLAUDE.md convention (`date "+%Y-%m-%d %H:%M %Z"`).
## Files touched
| File | Change |
| --- | --- |
| [go/internal/web/assets_test.go](../../go/internal/web/assets_test.go) | **new** — two tests (embed completeness + `/static/*` mux) |
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) | tick M6.7, bump "last updated" |
| [CHANGELOG.md](../../CHANGELOG.md) | new top entry |
No production source files change. (If extracting the static handler
reads cleaner, a 4-line refactor in
[server.go](../../go/internal/web/server.go) is acceptable but optional.)
## Branch + MR
Per project convention this is a feature, so:
```bash
git checkout -b feat/go-m6-7-embed-verify
# … commits …
git push -u origin feat/go-m6-7-embed-verify
tea pr create --title "feat(go): M6.7 — single-binary embed verification" \
--description "<short body referencing M6.7>" --base main \
--head feat/go-m6-7-embed-verify
```
## Verification
After implementation:
1. `make go-test` → green (new `TestEmbedCompleteness` and `TestStaticAssetsServed` pass).
2. `make go-lint` → clean.
3. Run the manual transcript in §2 above — all curls succeed, no
"template not found" or 404 on static assets.
4. `make go-build && docker build -f go/build/Dockerfile -t fuj-go:m6-7 go/`
succeeds; `docker run --rm -p 8080:8080 fuj-go:m6-7` serves `/adults`
with stylesheet attached (visual smoke test in browser).
## Out of scope
- Re-architecting how templates are parsed or served.
- Compressing / fingerprinting static assets (a separate concern).
- Live integration test with real Sheets data — covered later in M7.

View File

@@ -1,269 +0,0 @@
# 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 4a4c (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.

View File

@@ -1,90 +0,0 @@
# 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.

View File

@@ -1,146 +0,0 @@
# 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`.

View File

@@ -1,112 +0,0 @@
# 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.)

View File

@@ -88,7 +88,7 @@ func serverCmd(args []string) {
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
os.Exit(1)
}
fioClients := buildFioClients(cfg)
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
actions := web.ActionHandlers{
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)
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli,
banksync.SyncOpts{From: from, To: to, Sort: true})
if err != nil {
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)
os.Exit(1)
}
fioClients := buildFioClients(cfg)
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable}
if *fromStr != "" && *toStr != "" {
@@ -213,7 +213,7 @@ func syncCmd(args []string) {
}
}
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli, opts)
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
os.Exit(1)
@@ -274,13 +274,3 @@ Commands:
sync Sync Fio transactions to payments sheet
infer Infer payment details in payments sheet`)
}
// buildFioClients constructs one fio.Client per configured account.
// Each client uses the account's token if available, otherwise the transparent-scraper path.
func buildFioClients(cfg config.Config) []fio.Client {
clients := make([]fio.Client, 0, len(cfg.LoadedAccounts))
for _, a := range cfg.LoadedAccounts {
clients = append(clients, fio.New(a.Token, a.AcctNum, nil))
}
return clients
}

View File

@@ -7,28 +7,6 @@ import (
"time"
)
// Account describes a Fio bank account.
type Account struct {
IBAN string // e.g. "CZ0820100000002502035405"
AcctNum string // bare account number, e.g. "2502035405"
TokenEnv string // env var name holding the optional Fio API token
Primary bool // true for the account QR codes default to
}
// LoadedAccount is an Account with its token resolved from the environment.
type LoadedAccount struct {
Account
Token string // value of os.Getenv(Account.TokenEnv); empty → transparent-scraper path
}
// Accounts is the hardcoded list of Fio bank accounts to sync from.
// The first entry with Primary=true is used for QR codes.
// Tokens are loaded at runtime from each account's TokenEnv.
var Accounts = []Account{
{IBAN: "CZ0820100000002502035405", AcctNum: "2502035405", TokenEnv: "FIO_API_TOKEN_NEW", Primary: true},
{IBAN: "CZ8520100000002800359168", AcctNum: "2800359168", TokenEnv: "FIO_API_TOKEN_OLD"},
}
// Google Sheets IDs — change in code if sheets change (not from env).
const (
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
@@ -56,39 +34,29 @@ var CacheSheetMap = map[string]string{
// Mirrors scripts/config.py.
type Config struct {
CredentialsPath string
QRAccount string // IBAN of the primary account used for QR codes
LoadedAccounts []LoadedAccount // all accounts to sync, tokens resolved from env
BankAccount string
CacheDir string
CacheTTL time.Duration
CacheAPICheckTTL time.Duration
DriveTimeout time.Duration
LogLevel string
FioAPIToken string
ServerAddr string
MonthsToShow int // show last N month columns; 0 means show all
}
// Load reads configuration from the environment, applying defaults that
// match the Python side.
func Load() Config {
loaded := make([]LoadedAccount, len(Accounts))
var qrAccount string
for i, a := range Accounts {
loaded[i] = LoadedAccount{Account: a, Token: os.Getenv(a.TokenEnv)}
if a.Primary {
qrAccount = a.IBAN
}
}
return Config{
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
QRAccount: qrAccount,
LoadedAccounts: loaded,
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
CacheDir: env("CACHE_DIR", "tmp/go"),
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
LogLevel: env("LOG_LEVEL", "INFO"),
FioAPIToken: env("FIO_API_TOKEN", ""),
ServerAddr: env("SERVER_ADDR", ":8080"),
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
}
}
@@ -123,12 +91,3 @@ func envDuration(key string, defaultSeconds int) time.Duration {
}
return time.Duration(defaultSeconds) * time.Second
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}

View File

@@ -11,7 +11,7 @@ const (
var AdultFeeMonthlyRate = map[string]int{
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
"2026-01": 750, "2026-02": 750, "2026-03": 350,
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 600, "2026-08": 600,
"2026-04": 700, "2026-05": 700,
}
// CalculateFee returns the adult fee in CZK for attendanceCount practices in

View File

@@ -5,40 +5,24 @@ import "testing"
func TestCalculateFee(t *testing.T) {
t.Parallel()
// mustRate returns the configured rate for a month that must be in the map.
// It panics immediately if the month is absent — so if a rate entry is ever
// removed, the test fails loudly rather than silently comparing against
// Go's zero value.
mustRate := func(month string) int {
r, ok := AdultFeeMonthlyRate[month]
if !ok {
panic("test month not in AdultFeeMonthlyRate: " + month)
}
return r
}
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
tests := []struct {
name string
count int
month string
want int
}{
// Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", 0},
{"zero empty month", 0, "", 0},
// Single practice returns AdultFeeSingle regardless of month.
{"single practice", 1, "2026-05", AdultFeeSingle},
{"single ignores monthKey", 1, "unknown", AdultFeeSingle},
// Two+ practices for a configured month: must use the map value, not the default.
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
// when rates are updated — the assertion verifies dispatch logic, not rate values.
{"two practices configured month", 2, "2026-05", mustRate("2026-05")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"two practices early season", 2, "2025-09", mustRate("2025-09")},
{"high count same as two", 5, "2026-05", mustRate("2026-05")},
// Two+ practices for an unknown/future month: must fall back to AdultFeeDefault.
{"unknown future month falls back", 2, "2027-01", AdultFeeDefault},
{"empty month falls back", 2, "", AdultFeeDefault},
{"single practice", 1, "2026-05", 200},
{"single ignores monthKey", 1, "unknown", 200},
{"two practices configured month", 2, "2026-05", 700},
{"two practices reduced march", 2, "2026-03", 350},
{"two practices early season", 2, "2025-09", 750},
{"high count same as two", 5, "2026-05", 700},
{"unknown future month falls back", 2, "2027-01", 700},
{"empty month falls back", 2, "", 700},
}
for _, tc := range tests {

View File

@@ -5,37 +5,24 @@ import "testing"
func TestCalculateJuniorFee(t *testing.T) {
t.Parallel()
// mustRate returns the configured rate for a month that must be in the map.
// Panics immediately if the month is absent so a removed entry causes a loud
// failure rather than a silent comparison against Go's zero value.
mustRate := func(month string) Expected {
r, ok := JuniorFeeMonthlyRate[month]
if !ok {
panic("test month not in JuniorFeeMonthlyRate: " + month)
}
return Expected{Value: r}
}
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
tests := []struct {
name string
count int
month string
want Expected
}{
// Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
{"zero empty month", 0, "", Expected{Value: 0}},
// Single practice returns the Unknown sentinel regardless of month.
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
// Two+ practices for a configured month: must use the map value, not the default.
{"two practices unconfigured month", 2, "2026-05", Expected{Value: JuniorFeeDefault}},
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault.
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
{"empty month falls back", 2, "", Expected{Value: 500}},
}
for _, tc := range tests {

View File

@@ -35,13 +35,13 @@ type SyncOpts struct {
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
}
// SyncToSheets fetches Fio transactions from all provided clients and appends
// new ones to the payments sheet. Returns the number of rows appended.
// SyncToSheets fetches Fio transactions and appends new ones to the payments sheet.
// Returns the number of rows appended.
// Ports scripts/sync_fio_to_sheets.py sync_to_sheets.
func SyncToSheets(
ctx context.Context,
spreadsheetID string,
fioClients []fio.Client,
fioClient fio.Client,
sh sheetsWriter,
opts SyncOpts,
) (int, error) {
@@ -84,14 +84,10 @@ func SyncToSheets(
from = to.AddDate(0, 0, -days)
}
// 3. Fetch Fio transactions from each account and combine.
var txns []fio.Transaction
for _, client := range fioClients {
got, err := client.FetchTransactions(ctx, from, to)
if err != nil {
return 0, fmt.Errorf("sync: fetch fio: %w", err)
}
txns = append(txns, got...)
// 3. Fetch Fio transactions.
txns, err := fioClient.FetchTransactions(ctx, from, to)
if err != nil {
return 0, fmt.Errorf("sync: fetch fio: %w", err)
}
if opts.DryRun {
fmt.Printf("Dry run: window %s to %s, fetched %d transaction(s) from Fio\n",

View File

@@ -20,7 +20,7 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
}}
fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
@@ -51,7 +51,7 @@ func TestSyncToSheets_Dedup(t *testing.T) {
}}
fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
@@ -72,7 +72,7 @@ func TestSyncToSheets_NoNewTxns(t *testing.T) {
}}
fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
@@ -92,7 +92,7 @@ func TestSyncToSheets_MissingHeader(t *testing.T) {
}}
fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
@@ -105,7 +105,7 @@ func TestSyncToSheets_Sort(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
_, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30, Sort: true})
_, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30, Sort: true})
if err != nil {
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)
to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{From: from, To: to})
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{From: from, To: to})
if err != nil {
t.Fatal(err)
}
@@ -131,7 +131,7 @@ func TestSyncToSheets_DryRun(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh,
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh,
SyncOpts{Days: 30, Sort: true, DryRun: true})
if err != nil {
t.Fatal(err)
@@ -144,40 +144,6 @@ func TestSyncToSheets_DryRun(t *testing.T) {
}
}
func TestSyncToSheets_MultiAccount(t *testing.T) {
txnsA := []fio.Transaction{
{Date: "2026-04-10", Amount: 700, Sender: "Alice", Message: "april", VS: "1", BankID: "A1"},
}
txnsB := []fio.Transaction{
{Date: "2026-04-11", Amount: 500, Sender: "Bob", Message: "duben", VS: "2", BankID: "B1"},
}
// One transaction that duplicates the first one from account A (same sync_id).
dupID := syncIDFor(txnsA[0])
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:K": {
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
{"2026-04-10", 700.0, "", "", "", "", "Alice", "1", "april", "A1", dupID},
},
}}
fakeA := &fio.Fake{Transactions: txnsA}
fakeB := &fio.Fake{Transactions: txnsB}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fakeA, fakeB}, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("want 1 new row (B1 from account B; A1 is duplicate), got %d", n)
}
if len(sh.Appended) != 1 || len(sh.Appended[0].Rows) != 1 {
t.Fatalf("want exactly 1 row appended, got %v", sh.Appended)
}
row := sh.Appended[0].Rows[0]
if row[6] != "Bob" {
t.Errorf("expected Bob's row, got sender=%v", row[6])
}
}
// syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction.
func syncIDFor(tx fio.Transaction) string {
currency := tx.Currency

View File

@@ -39,5 +39,4 @@ type AdultsResponse struct {
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
}

View File

@@ -138,9 +138,8 @@ func buildAdultsResponse(
Unmatched: unmatched,
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.QRAccount,
BankAccount: cfg.BankAccount,
CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
}
}

View File

@@ -134,9 +134,8 @@ func buildJuniorsResponse(
Unmatched: unmatched,
AttendanceURL: juniorURL,
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.QRAccount,
BankAccount: cfg.BankAccount,
CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
}
}

View File

@@ -38,5 +38,4 @@ type JuniorsResponse struct {
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
}

View File

@@ -1,93 +0,0 @@
package web
import (
"io/fs"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// TestEmbedCompleteness guards against a new template or static file being
// added to disk but missing from the embedded FS (e.g. a new directory that
// the //go:embed glob does not match).
func TestEmbedCompleteness(t *testing.T) {
cases := []struct {
name string
diskDir string
embedFS fs.FS
embedRoot string
}{
{"templates", "templates", templateFS, "templates"},
{"static", "static", staticFS, "static"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diskFS := os.DirFS(tc.diskDir)
_ = fs.WalkDir(diskFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
embPath := tc.embedRoot + "/" + path
if _, statErr := fs.Stat(tc.embedFS, embPath); statErr != nil {
t.Errorf("file %q exists on disk but is missing from embed.FS (%v)", embPath, statErr)
}
return nil
})
})
}
}
// TestStaticAssetsServed verifies that /static/* is served from the embedded
// FS through the same mux wiring used in server.go, so a standalone binary
// with no adjacent static/ directory still delivers assets.
func TestStaticAssetsServed(t *testing.T) {
subFS, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatalf("fs.Sub static: %v", err)
}
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(subFS)))
cases := []struct {
path string
wantCT string
wantSnippet string
}{
{"/static/css/app.css", "text/css", "body {"},
{"/static/js/member-detail.js", "javascript", "Member-detail modal"},
{"/static/js/filters.js", "javascript", ""},
{"/static/js/payment-qr.js", "javascript", ""},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s: status %d, want 200", tc.path, w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, tc.wantCT) {
t.Errorf("GET %s: Content-Type %q, want it to contain %q", tc.path, ct, tc.wantCT)
}
if tc.wantSnippet != "" && !strings.Contains(w.Body.String(), tc.wantSnippet) {
t.Errorf("GET %s: body missing expected snippet %q", tc.path, tc.wantSnippet)
}
})
}
// Sanity: unknown path → 404 (file server doesn't fall through silently)
t.Run("missing-file", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/static/css/nonexistent.css", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("unknown static path: status %d, want 404", w.Code)
}
})
}

View File

@@ -126,13 +126,13 @@ func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
amount := q.Get("amount")
message := q.Get("message")
if account == "" {
account = h.apiHandler.Config.QRAccount
account = h.apiHandler.Config.BankAccount
}
if amount == "" {
amount = "0"
}
payload := BuildSPD(account, amount, message, h.apiHandler.Config.QRAccount)
payload := BuildSPD(account, amount, message, h.apiHandler.Config.BankAccount)
png, err := RenderQRCode(payload)
if err != nil {
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)

View File

@@ -50,7 +50,7 @@ func fixtureHandler(t *testing.T) *api.Handler {
t.Helper()
return &api.Handler{
Sources: fixtureSources{},
Config: config.Config{QRAccount: "CZ0000000000000000000000"},
Config: config.Config{BankAccount: "CZ0000000000000000000000"},
}
}

View File

@@ -12,8 +12,7 @@
const container = document.getElementById('filterContainer');
if (!container) return;
const currentMonth = container.dataset.currentMonth || '';
const monthsToShow = parseInt(container.dataset.monthsToShow || '0', 10);
const currentMonth = container.dataset.currentMonth || '';
const nameInput = document.getElementById('nameFilter');
const fromSelect = document.getElementById('fromMonth');
@@ -89,10 +88,4 @@
// ── Initialise ────────────────────────────────────────────────────────────
hideFutureMonths();
// Default the from-select to show only the last N months.
if (monthsToShow > 0 && toSelect.value !== '') {
const defaultFrom = Math.max(0, parseInt(toSelect.value, 10) - monthsToShow + 1);
fromSelect.value = String(defaultFrom);
applyMonthFilter();
}
}());

View File

@@ -12,7 +12,7 @@
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">

View File

@@ -12,7 +12,7 @@
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">

View File

@@ -143,9 +143,6 @@
},
"current_month": {
"type": "string"
},
"months_to_show": {
"type": "integer"
}
},
"additionalProperties": false,
@@ -164,8 +161,7 @@
"attendance_url",
"payments_url",
"bank_account",
"current_month",
"months_to_show"
"current_month"
]
},
"Credit": {

View File

@@ -187,9 +187,6 @@
},
"current_month": {
"type": "string"
},
"months_to_show": {
"type": "integer"
}
},
"additionalProperties": false,
@@ -208,8 +205,7 @@
"attendance_url",
"payments_url",
"bank_account",
"current_month",
"months_to_show"
"current_month"
]
},
"MemberOtherEntry": {

View File

@@ -21,10 +21,7 @@ ADULT_FEE_MONTHLY_RATE = {
"2026-02": 750,
"2026-03": 350,
"2026-04": 700,
"2026-05": 450,
"2026-06": 600,
"2026-07": 600,
"2026-08": 600,
"2026-05": 700,
}
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices

View File

@@ -21,18 +21,8 @@ PAYMENTS_SHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
# Attendance sheet tab GIDs
JUNIOR_SHEET_GID = "1213318614"
# Bank accounts — hardcoded list mirroring go/internal/config/config.go.
# 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"])
# Bank
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
# Cache settings
CACHE_DIR = PROJECT_ROOT / "tmp"
@@ -40,9 +30,6 @@ DRIVE_TIMEOUT = 10 # seconds
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
# Display settings
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5)) # show last N months; 0 = show all
# Maps cache keys to their source sheet IDs (used by cache_utils)
CACHE_SHEET_MAP = {
"attendance_regular": ATTENDANCE_SHEET_ID,

View File

@@ -103,7 +103,7 @@ def parse_czech_date(s: str) -> str | None:
def fetch_transactions_transparent(
date_from: str, date_to: str, account_id: str
date_from: str, date_to: str, account_id: str = "2800359168"
) -> list[dict]:
"""Fetch transactions from Fio transparent account HTML page.
@@ -206,47 +206,22 @@ def fetch_transactions_api(
return transactions
def fetch_transactions_for(account: dict, date_from: str, date_to: str) -> list[dict]:
"""Fetch transactions for a single loaded account dict (from config.LOADED_ACCOUNTS).
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"]
def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
"""Fetch transactions, using API if token available, else transparent page."""
token = os.environ.get("FIO_API_TOKEN", "").strip()
if token:
print(f"fio: account {acct_num}: using authenticated API, window {date_from}..{date_to}", file=sys.stderr)
print(f"fio: using authenticated API, window {date_from}..{date_to}", file=sys.stderr)
return fetch_transactions_api(token, date_from, date_to)
print(
f"fio: account {acct_num}: using transparent page (no token — expect publishing lag), "
f"window {date_from}..{date_to}",
f"fio: using transparent page (FIO_API_TOKEN unset — expect publishing lag), "
f"window {date_from}..{date_to}, account=2800359168",
file=sys.stderr,
)
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
return fetch_transactions_transparent(
from_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

View File

@@ -26,15 +26,6 @@ def canonical_member_key(name: str) -> str:
return re.sub(r"\s+", " ", normalize(name)).strip()
def _expected_amount(value) -> float:
"""Numeric value of an expected fee; non-numeric markers like '?' → 0.
Juniors with exactly 1 session get expected='?' (manual-review marker).
Treat those as 0 for arithmetic so payments become surplus/credit.
"""
return value if isinstance(value, (int, float)) else 0
# ---------------------------------------------------------------------------
# Name matching
# ---------------------------------------------------------------------------
@@ -475,7 +466,7 @@ def reconcile(
if not in_window:
continue
total_expected = sum(_expected_amount(e) for _, e in in_window)
total_expected = sum(e for _, e in in_window)
if total_expected > 0:
# Fill-first: iterate in_window in matched_months order (chronological by
@@ -486,7 +477,7 @@ def reconcile(
remaining = in_window_share
for m, exp in in_window:
paid_so_far = ledger[member_name][m]["paid"]
deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)
deficit = max(0.0, float(exp) - paid_so_far)
alloc = min(remaining, deficit)
if alloc <= 0:
continue
@@ -518,7 +509,7 @@ def reconcile(
final_balances: dict[str, int] = {}
for name in member_names:
window_balance = sum(
int(mdata["paid"]) - _expected_amount(mdata["expected"])
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
for mdata in ledger[name].values()
)
final_balances[name] = window_balance + credits.get(name, 0)

View File

@@ -12,7 +12,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2 import service_account
from googleapiclient.discovery import build
from fio_utils import fetch_transactions_all
from fio_utils import fetch_transactions
from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
@@ -188,7 +188,7 @@ def sync_to_sheets(
dt_str = date_to.strftime("%Y-%m-%d")
print(f"Fetching Fio transactions from {df_str} to {dt_str}...")
transactions = fetch_transactions_all(df_str, dt_str)
transactions = fetch_transactions(df_str, dt_str)
print(f"Found {len(transactions)} transactions.")
if dry_run:

View File

@@ -1045,7 +1045,6 @@
});
toSelect.value = maxMonthIdx;
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
applyMonthFilter();
})();
</script>

View File

@@ -1026,7 +1026,6 @@
});
toSelect.value = maxMonthIdx;
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
applyMonthFilter();
})();
</script>