Compare commits
35 Commits
689f1c01fd
...
0.39
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c14f0b22 | |||
| 6d7dbfa624 | |||
| c00111cff1 | |||
| d263d8a534 | |||
| af030c8255 | |||
| ad127d36ea | |||
| 29938d7a0c | |||
| 1df1863725 | |||
| 995abfacb2 | |||
| f047150004 | |||
| 6f2994b8ad | |||
| c2a381bb63 | |||
| c0487e3af0 | |||
| 37fc17cf9c | |||
| 20b618685f | |||
| 72e29b1882 | |||
| 241fecfb2c | |||
| 723591cbce | |||
| 69af4c1e3b | |||
| 152908fec6 | |||
| fbc5a41d12 | |||
| 7f801d27f5 | |||
| 10e2e9dc04 | |||
| 8734089223 | |||
| aaa876e593 | |||
| d981392593 | |||
| f25552eef2 | |||
| 4276d7b915 | |||
| f6ba85b18f | |||
| 919845518c | |||
| fe935235e8 | |||
| e22ab8cc49 | |||
| e53e238ca6 | |||
| 309c26f209 | |||
| 01573faced |
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
105
.gitea/workflows/gitops-update.yaml
Normal file
105
.gitea/workflows/gitops-update.yaml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
name: GitOps image update
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Auto-fires when "Build and Push" completes successfully (tag push).
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build and Push"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
# Manual trigger for dry-runs and one-off bumps.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Git tag to deploy (without the -go suffix, e.g. 0.37)"
|
||||||
|
required: true
|
||||||
|
dry_run:
|
||||||
|
description: "Dry run — print diff, do not open a PR"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
uh_cli_version:
|
||||||
|
description: "uh-cli version override (e.g. v0.2.0). Defaults to v0.1.0."
|
||||||
|
required: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
TEA_VERSION: "0.9.2"
|
||||||
|
# Resolved priority: manual input → repo/org variable → hardcoded default.
|
||||||
|
UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitops-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Skip if triggered by workflow_run that did not succeed.
|
||||||
|
if: >
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
github.event.workflow_run.conclusion == 'success'
|
||||||
|
container:
|
||||||
|
image: ubuntu:latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install git, curl, ca-certificates, jq
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends git curl ca-certificates jq
|
||||||
|
|
||||||
|
- name: Install tea
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \
|
||||||
|
-o /usr/local/bin/tea
|
||||||
|
chmod +x /usr/local/bin/tea
|
||||||
|
|
||||||
|
- name: Install uh-cli
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64" \
|
||||||
|
-o /usr/local/bin/uh-cli
|
||||||
|
chmod +x /usr/local/bin/uh-cli
|
||||||
|
|
||||||
|
- name: Resolve image tag
|
||||||
|
id: resolve
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${{ inputs.tag }}-go"
|
||||||
|
else
|
||||||
|
# workflow_run: head_branch is not populated for tag pushes in Gitea Actions.
|
||||||
|
# Look up the tag name that points to the triggering commit SHA via the API.
|
||||||
|
SHA="${{ github.event.workflow_run.head_sha }}"
|
||||||
|
GIT_TAG=$(curl -fsSL \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"https://gitea.home.hrajfrisbee.cz/api/v1/repos/${{ github.repository }}/tags?limit=50" \
|
||||||
|
| jq -r --arg sha "$SHA" '.[] | select(.commit.sha == $sha) | .name')
|
||||||
|
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
|
||||||
|
fi
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved image: ${IMAGE}"
|
||||||
|
|
||||||
|
- name: Configure git identity and credentials
|
||||||
|
run: |
|
||||||
|
git config --global user.name "uh-cli bot"
|
||||||
|
git config --global user.email "bot@hrajfrisbee.cz"
|
||||||
|
# Store credentials separately so the --git-repo URL stays clean.
|
||||||
|
# Tea matches the login URL against the remote URL; embedded credentials
|
||||||
|
# break that matching and cause "path segment [0] is empty" on pr create.
|
||||||
|
git config --global credential.helper store
|
||||||
|
echo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz" >> ~/.git-credentials
|
||||||
|
|
||||||
|
- name: Authenticate tea
|
||||||
|
run: |
|
||||||
|
tea login add \
|
||||||
|
--name ci \
|
||||||
|
--url https://gitea.home.hrajfrisbee.cz \
|
||||||
|
--token "$GITEA_TOKEN"
|
||||||
|
|
||||||
|
- name: Open image-update PR (or dry run)
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
uh-cli -v gitops deployment update \
|
||||||
|
--deployment-name fuj-management \
|
||||||
|
--deployment-namespace fuj \
|
||||||
|
--set-image "${{ steps.resolve.outputs.image }}" \
|
||||||
|
--git-repo "https://gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
|
||||||
|
--git-path gitops/home-kubernetes \
|
||||||
|
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ tmp/
|
|||||||
|
|
||||||
# go build output
|
# go build output
|
||||||
bin/
|
bin/
|
||||||
|
go/parity
|
||||||
|
|||||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -1,5 +1,83 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-06-12 19:32 CEST — feat(ci): gitops image-update PR workflow
|
||||||
|
|
||||||
|
- Added `.gitea/workflows/gitops-update.yaml`: after each successful Go image build, `uh-cli gitops deployment update` opens a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to the new image tag.
|
||||||
|
- Supports `workflow_run` auto-trigger and `workflow_dispatch` with `dry_run` / `uh_cli_version` inputs.
|
||||||
|
- Requires `GITOPS_TOKEN` repo secret (Gitea PAT with write+PR access to `home-kubernetes`).
|
||||||
|
|
||||||
|
## 2026-05-24 21:58 CEST — feat(fees): update adult monthly rates for 2026-05 through 2026-08
|
||||||
|
|
||||||
|
- 2026-05: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added).
|
||||||
|
- Mirrored in both `scripts/attendance.py` and `go/internal/domain/fees/fees.go`.
|
||||||
|
|
||||||
|
## 2026-05-24 21:42 CEST — feat: multi-account Fio sync + switch QR default to 2502035405/2010
|
||||||
|
|
||||||
|
- Added second bank account `2502035405/2010` (IBAN `CZ0820100000002502035405`) to sync.
|
||||||
|
- Both accounts are fetched on every sync; dedup by existing sync_id keeps the payments sheet clean.
|
||||||
|
- QR codes now default to the new account (`CZ0820100000002502035405`).
|
||||||
|
- Go: `config.go` gains hardcoded `Accounts`/`LoadedAccount` slice; `Config.BankAccount` renamed to `Config.QRAccount`; `FioAPIToken` removed (tokens are per-account via `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
|
||||||
|
- Go: `SyncToSheets` now accepts `[]fio.Client`; new `TestSyncToSheets_MultiAccount` test.
|
||||||
|
- Python: `config.py` gains `ACCOUNTS` / `LOADED_ACCOUNTS`; `fio_utils.py` adds `fetch_transactions_for` and `fetch_transactions_all`; `sync_fio_to_sheets.py` uses `fetch_transactions_all`.
|
||||||
|
- Key files: `go/internal/config/config.go`, `go/internal/services/banksync/sync.go`, `go/cmd/fuj/main.go`, `scripts/config.py`, `scripts/fio_utils.py`, `scripts/sync_fio_to_sheets.py`.
|
||||||
|
|
||||||
|
## 2026-05-11 23:58 CEST — fix(reconcile): fill earliest month deficit first in multi-month allocations
|
||||||
|
|
||||||
|
- Multi-month payment allocation now fills the earliest in-window deficit first and spills
|
||||||
|
any remainder to later months, accounting for prior transactions' contributions to each month.
|
||||||
|
Previously a single transaction was split proportionally to each month's total expected fee,
|
||||||
|
ignoring what earlier transactions had already paid — surfaced by Matyáš Thér's 200+550 case
|
||||||
|
showing 566/183 instead of 500/250.
|
||||||
|
- Files: `scripts/match_payments.py`, `go/internal/domain/reconcile/reconcile.go`, tests, parity fixtures.
|
||||||
|
|
||||||
|
## 2026-05-11 22:56 CEST — fix(python): parse Fio 2-digit-year dates + add `make sync-debug` dry-run tool
|
||||||
|
|
||||||
|
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
|
||||||
|
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
|
||||||
|
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
|
||||||
|
## 2026-05-08 15:24 CEST — feat(go): M6.7 — single-binary embed verification
|
||||||
|
|
||||||
|
- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
|
||||||
|
- Added `go/internal/web/assets_test.go` with two tests: `TestEmbedCompleteness` (walks disk vs embed.FS to catch forgotten files) and `TestStaticAssetsServed` (hits `/static/css/app.css` and all JS files through the mux, asserts 200 + Content-Type + non-empty body + 404 for unknown paths).
|
||||||
|
- Closes M6; single binary confirmed self-contained with no adjacent `templates/` or `static/` required at runtime.
|
||||||
|
- Key files: `go/internal/web/assets_test.go` (new).
|
||||||
|
|
||||||
|
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
|
||||||
|
|
||||||
|
- Restored the Python `showPayQR` in-page modal UX that was lost in M6.6 (Pay buttons were navigating the tab to the raw `/qr` PNG).
|
||||||
|
- Replaced `<a href="{{qrHref ...}}">Pay</a>` with `<button data-name|amount|month|raw-month>` on `/adults` and `/juniors`; click is handled by a new `static/js/payment-qr.js` IIFE module that opens `#qrModal` with title, account, amount, message, and the QR image.
|
||||||
|
- Added `#qrModal` markup to both templates; CSS `display:none` / `.active{display:flex}` rules added (content rules were already present from M6.1). `Esc`, `[close]`, and outside-click all dismiss; coexists with the M6.5 member-detail modal.
|
||||||
|
- Removed the now-dead `qrHref` / `qrHrefAll` template helpers from `render.go`.
|
||||||
|
- Markup tests in `html_handler_test.go` assert modal IDs, script tag, `data-bank-account`, and that no bare `href="/qr"` links remain.
|
||||||
|
- Key files: `go/internal/web/static/js/payment-qr.js`, `go/internal/web/templates/adults.tmpl`, `go/internal/web/templates/juniors.tmpl`, `go/internal/web/render.go`, `go/internal/web/static/css/app.css`.
|
||||||
|
|
||||||
|
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
|
||||||
|
|
||||||
|
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
|
||||||
|
- Implemented `GET /sync-bank`: runs Fio sync → infer payments → cache flush, captures output into `sync.tmpl` page with success/error banner.
|
||||||
|
- Implemented `GET /flush-cache` + `POST /flush-cache`: form + action that deletes cache files and shows deleted count.
|
||||||
|
- Added `GET /version` as a JSON alias of `GET /api/version` (Python parity).
|
||||||
|
- Added `FlushCache() (int, error)` to `membership.Sources` interface; implemented on `realSources` via `cache.FileCache.Flush()`.
|
||||||
|
- Introduced `web.ActionHandlers{BankSync}` — closure-based dep injection for sync, constructed in `serverCmd` with fio + sheets clients.
|
||||||
|
- New dependency: `github.com/skip2/go-qrcode`.
|
||||||
|
- Key files: `go/internal/web/qr.go`, `go/internal/web/html_handler.go`, `go/internal/web/server.go`, `go/internal/services/membership/loader.go`, `go/internal/web/templates/sync.tmpl`, `go/internal/web/templates/flush_cache.tmpl`.
|
||||||
|
|
||||||
|
## 2026-05-08 13:19 CEST — feat(go): M6.5 — member-detail modal JS module
|
||||||
|
|
||||||
|
- Added `static/js/member-detail.js`: fetches `/api/adults` or `/api/juniors` once on page load, caches the response, renders a per-member detail modal on `[i]` row click.
|
||||||
|
- Modal sections: status-per-month table, fee exceptions, other transactions, matched payment history, toggleable raw-payments debug view.
|
||||||
|
- Keyboard nav: `Esc` closes, `↑`/`↓` walk visible (name-filtered) rows; click outside modal content closes it.
|
||||||
|
- Added `[i]` info icon and `#memberModal` markup to `adults.tmpl` and `juniors.tmpl`; all CSS was already in place from M6.1.
|
||||||
|
- Added `TestModalMarkup` assertions to `html_handler_test.go`.
|
||||||
|
|
||||||
|
## 2026-05-08 12:48 CEST — feat(go): M6.4 — Go-native /payments page
|
||||||
|
|
||||||
|
- Extracted `AssemblePayments(ctx)` from the inlined JSON handler body, following the M6.2/M6.3 `AssembleAdults`/`AssembleJuniors` pattern.
|
||||||
|
- Added `PaymentsPageData` to `render.go`; wired `HTMLHandler.ServePayments` to call `AssemblePayments` and render the new template.
|
||||||
|
- Replaced the "Coming in M6.4" placeholder in `payments.tmpl` with the full grouped-by-person ledger: alphabetical `<h2>` member blocks, each with a `txn-table` (Date / Amount / Purpose / Bank Message), newest-first rows, `"Unmatched / Unknown"` bucket.
|
||||||
|
- Appended ledger CSS classes to `app.css` (`.ledger-container`, `.member-block`, `.txn-table`, `.txn-{date,amount,purpose,message}`, `tr:hover`).
|
||||||
|
- Added `TestPaymentsPage` markup test. No filters, no JS — matches the Python page.
|
||||||
|
|
||||||
## 2026-05-08 11:25 CEST — feat(go): M6.3 — Go-native /juniors page
|
## 2026-05-08 11:25 CEST — feat(go): M6.3 — Go-native /juniors page
|
||||||
|
|
||||||
- Extracted `AssembleJuniors(ctx)` from the JSON handler so HTML and JSON share one view-model path (mirrors `AssembleAdults`).
|
- Extracted `AssembleJuniors(ctx)` from the JSON handler so HTML and JSON share one view-model path (mirrors `AssembleAdults`).
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -35,6 +35,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"
|
||||||
@@ -125,6 +126,9 @@ sync-2025: $(PYTHON)
|
|||||||
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) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --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) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table
|
||||||
|
|
||||||
infer: $(PYTHON)
|
infer: $(PYTHON)
|
||||||
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
|
|||||||
6
app.py
6
app.py
@@ -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():
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-
|
|||||||
|
|
||||||
**Current milestone:** M6 — Go-native HTML frontend
|
**Current milestone:** M6 — Go-native HTML frontend
|
||||||
**Started:** 2026-05-04
|
**Started:** 2026-05-04
|
||||||
**Last updated:** 2026-05-08 (M6.2 merged)
|
**Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
@@ -113,10 +113,11 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
|||||||
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
|
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
|
||||||
- [x] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` — `c85748b`
|
- [x] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` — `c85748b`
|
||||||
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
||||||
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
||||||
- [ ] **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, ↑/↓)
|
- [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`
|
||||||
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
|
||||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
- [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
|
||||||
|
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets — (pending merge)
|
||||||
|
|
||||||
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
|
**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.
|
||||||
|
|
||||||
|
|||||||
288
docs/plans/2026-05-08-1259-go-m6-5-modal-js.md
Normal file
288
docs/plans/2026-05-08-1259-go-m6-5-modal-js.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# M6.5 — Member-detail modal JS module (`/adults`, `/juniors`)
|
||||||
|
|
||||||
|
> Plan-mode note: per project convention this plan should live at
|
||||||
|
> `docs/plans/2026-05-08-HHMM-go-m6-5-modal-js.md` (use `date "+%Y-%m-%d-%H%M"`
|
||||||
|
> when copying). The first execution step after approval is to copy this file
|
||||||
|
> there. Branch: `feat/go-m6-5-modal-js` off `main`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go port of the Flask app is at milestone **M6.5**: the `/adults` and
|
||||||
|
`/juniors` pages currently render their tables ([go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl),
|
||||||
|
[go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl))
|
||||||
|
but have no row-click member-detail modal — the feature that lets a user click
|
||||||
|
a member's row to inspect status per month, exception overrides, "other"
|
||||||
|
transactions, the matched payment list, and a debug raw-payments view.
|
||||||
|
|
||||||
|
The Python implementation lives inline in
|
||||||
|
[templates/adults.html:718-993](templates/adults.html#L718-L993)
|
||||||
|
(matching code in [templates/juniors.html](templates/juniors.html) — same
|
||||||
|
modal markup and JS). It uses globals injected via Jinja:
|
||||||
|
`memberData`, `sortedMonths`, `monthLabels`, `rawPaymentsByPerson`. M6.5
|
||||||
|
replaces this with a clean static JS module.
|
||||||
|
|
||||||
|
Per milestone wording — *"fetches `/api/adults` (or juniors), renders
|
||||||
|
status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)"* —
|
||||||
|
and per the user's design choice: the JS module fetches `/api/<page>` **once
|
||||||
|
on page load** and caches the response in a module-scoped variable. Subsequent
|
||||||
|
row clicks render synchronously from cache. This keeps HTML purely
|
||||||
|
server-rendered and reuses the parity-tested JSON contract already shipped in
|
||||||
|
M5.
|
||||||
|
|
||||||
|
All the building blocks are already in place:
|
||||||
|
|
||||||
|
- **JSON API**: `/api/adults` and `/api/juniors` already return
|
||||||
|
`member_data`, `month_labels`, `raw_payments` as nested objects (see
|
||||||
|
[go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42)
|
||||||
|
and the analogous juniors response). Junior `Expected` serialises as `"?"`
|
||||||
|
for the unknown sentinel via custom `MarshalJSON`
|
||||||
|
([go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29)) — JS sees a literal string `"?"`.
|
||||||
|
- **CSS**: `#memberModal`, `.modal-content`, `.modal-section`, `.modal-table`,
|
||||||
|
`.tx-list`, `.tx-item`, `.tx-meta`, `.tx-amount`, `.info-icon`, `.raw-toggle`
|
||||||
|
were lifted whole during M6.1 and live in
|
||||||
|
[go/internal/web/static/css/app.css](go/internal/web/static/css/app.css)
|
||||||
|
(lines 168-487). No CSS work needed.
|
||||||
|
- **Member-row hooks**: `tr.member-row[data-name="..."]` and `td.member-name`
|
||||||
|
are already rendered by both templates.
|
||||||
|
- **JS module convention**: `static/js/filters.js` is loaded with `<script src="/static/js/filters.js" defer>`;
|
||||||
|
M6.5 adds a sibling `static/js/member-detail.js` with the same loading
|
||||||
|
pattern.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### 1. Add `[i]` info icon + `#memberModal` markup to both templates
|
||||||
|
|
||||||
|
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
|
||||||
|
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl):
|
||||||
|
|
||||||
|
a. Inside the `<td class="member-name">{{$row.Name}}</td>` cell (line 59 of
|
||||||
|
each template), append the icon button:
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
|
||||||
|
```
|
||||||
|
|
||||||
|
Using a `data-name` attribute and a CSS class (rather than `onclick="..."`)
|
||||||
|
keeps the template free of inline JS — the module wires up the listener
|
||||||
|
itself.
|
||||||
|
|
||||||
|
b. After the closing `</div>` of the table-container / sections, before the
|
||||||
|
`<script>` tag at line 140 (adults) / 120 (juniors), inject the modal
|
||||||
|
markup. Mirror the Python structure verbatim
|
||||||
|
([templates/adults.html:644-707](templates/adults.html#L644-L707)):
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<div id="memberModal" class="modal" onclick="if (event.target === this) this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title" id="modalMemberName">—</span>
|
||||||
|
<span id="modalTier" class="modal-tier"></span>
|
||||||
|
<a href="#" class="modal-close" onclick="event.preventDefault(); document.getElementById('memberModal').classList.remove('active')">[x]</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status by Month</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead><tr><th>Month</th><th>Sessions</th><th>Expected</th><th>Paid</th><th>Status</th></tr></thead>
|
||||||
|
<tbody id="modalStatusBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display:none">
|
||||||
|
<div class="modal-section-title">Exception Overrides</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display:none">
|
||||||
|
<div class="modal-section-title">Other Payments</div>
|
||||||
|
<div id="modalOtherList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Matched Transactions</div>
|
||||||
|
<div id="modalTxList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">
|
||||||
|
Raw Payments (debug)
|
||||||
|
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
|
||||||
|
</div>
|
||||||
|
<div id="modalRawList" class="tx-list" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
c. Add the script tag alongside `filters.js`. To tell the module which API
|
||||||
|
endpoint to fetch, use a `data-page` attribute on `body` (or on the
|
||||||
|
filter container — already exists). Simplest: add `data-page="adults"` /
|
||||||
|
`data-page="juniors"` to the existing `<div id="filterContainer">`.
|
||||||
|
The module reads it on load.
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js)
|
||||||
|
|
||||||
|
A single shared module — both pages use the same DOM contract, so the same
|
||||||
|
JS works for both. Structure (vanilla ES, no build step, matches `filters.js`
|
||||||
|
style — IIFE, `'use strict'`, no globals leaked):
|
||||||
|
|
||||||
|
```js
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const page = container.dataset.page; // "adults" | "juniors"
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
let apiData = null; // cached /api/<page> response
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
|
// ── Data load ─────────────────────────────────────────────────────────
|
||||||
|
async function loadData() {
|
||||||
|
if (apiData) return apiData;
|
||||||
|
const r = await fetch('/api/' + page);
|
||||||
|
if (!r.ok) throw new Error('failed to fetch /api/' + page);
|
||||||
|
apiData = await r.json();
|
||||||
|
return apiData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-warm immediately so first click is instant.
|
||||||
|
loadData().catch(err => console.error('[member-detail]', err));
|
||||||
|
|
||||||
|
// ── Modal render ──────────────────────────────────────────────────────
|
||||||
|
async function showMember(name) { /* port of Python showMemberDetails */ }
|
||||||
|
function toggleRawPayments(ev) { /* port */ }
|
||||||
|
function closeModal() { document.getElementById('memberModal').classList.remove('active'); }
|
||||||
|
function navigateMember(dir) { /* port — walk visible .member-row */ }
|
||||||
|
|
||||||
|
// ── Wiring ────────────────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.info-icon[data-name]').forEach(el => {
|
||||||
|
el.addEventListener('click', ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
showMember(el.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
const modal = document.getElementById('memberModal');
|
||||||
|
if (e.key === 'Escape') { closeModal(); return; }
|
||||||
|
if (!modal.classList.contains('active')) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
```
|
||||||
|
|
||||||
|
The four `port` functions transcribe Python literally, with two adjustments:
|
||||||
|
|
||||||
|
- `data.tier` — read directly from the API response (already present as
|
||||||
|
`AdultsMemberData.Tier` / `JuniorsMemberData.Tier`).
|
||||||
|
- Junior `expected` may equal the literal string `"?"` (sentinel from
|
||||||
|
`Expected.MarshalJSON`). The status-row formatter must treat string
|
||||||
|
`expected` as "unknown / single attendance" and skip the numeric
|
||||||
|
comparisons — same logic Python uses when it sees `"?"`.
|
||||||
|
|
||||||
|
`navigateMember` walks `document.querySelectorAll('tr.member-row')` filtered
|
||||||
|
by `style.display !== 'none'`, finds the index whose `dataset.name`
|
||||||
|
matches `currentMemberName`, and calls `showMember` on the next/previous
|
||||||
|
visible row. Identical to Python lines 944-962 but uses `dataset.name`
|
||||||
|
instead of parsing `childNodes[0].textContent`.
|
||||||
|
|
||||||
|
### 3. Wire `data-page` attribute
|
||||||
|
|
||||||
|
Append `data-page="adults"` to the `filterContainer` div in `adults.tmpl`,
|
||||||
|
`data-page="juniors"` in `juniors.tmpl`. One-line edit per template.
|
||||||
|
|
||||||
|
### 4. Unit test
|
||||||
|
|
||||||
|
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
|
||||||
|
with assertions that the rendered HTML for `/adults` and `/juniors` contains:
|
||||||
|
|
||||||
|
- `class="info-icon"` with the expected `data-name` per fixture row,
|
||||||
|
- `id="memberModal"`,
|
||||||
|
- `<script src="/static/js/member-detail.js"`,
|
||||||
|
- `data-page="adults"` / `data-page="juniors"` on the filter container.
|
||||||
|
|
||||||
|
These are markup-level checks only — the JS module behaviour is not unit
|
||||||
|
tested in Go (would require headless-browser tooling that is out of scope
|
||||||
|
for this milestone). Manual browser verification covers it.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
| Action | Path |
|
||||||
|
|--------|------|
|
||||||
|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — add `[i]` icon, modal markup, `data-page`, script tag |
|
||||||
|
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
|
||||||
|
| new | `go/internal/web/static/js/member-detail.js` — modal module |
|
||||||
|
| edit | `go/internal/web/html_handler_test.go` — markup assertions |
|
||||||
|
|
||||||
|
No changes to: `app.css` (already complete), `assets.go` (the `embed.FS`
|
||||||
|
glob `static/js/*` already picks up new files), `server.go`, `render.go`,
|
||||||
|
the API handlers, or any domain code.
|
||||||
|
|
||||||
|
## Reused existing infrastructure
|
||||||
|
|
||||||
|
- [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — `/api/adults`, `/api/juniors` already serve the modal's data
|
||||||
|
- [go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42), [go/internal/web/api/juniors.go](go/internal/web/api/juniors.go) — typed responses
|
||||||
|
- [go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29) — `Expected` "?" sentinel marshalling
|
||||||
|
- [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — modal CSS already lifted
|
||||||
|
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — IIFE / `'use strict'` / `data-*` attribute style to match
|
||||||
|
- [templates/adults.html:725-962](templates/adults.html#L725-L962) — Python reference implementation to port (logic, not literal copy)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end:
|
||||||
|
|
||||||
|
1. `cd go && make go-build go-test go-lint` — all green.
|
||||||
|
2. `make web-go &` — boot Go on `:8080`.
|
||||||
|
3. Browser: `http://localhost:8080/adults`
|
||||||
|
- Click `[i]` next to a member name → modal opens with that member's data.
|
||||||
|
- Status table populated; cells colour-coded (cell-ok / cell-unpaid).
|
||||||
|
- Exception section visible only if member has exceptions; exception
|
||||||
|
amount shown with "*" override style.
|
||||||
|
- "Other Payments" section visible only if member has other-purpose
|
||||||
|
transactions.
|
||||||
|
- Matched transactions list newest-first; raw-payments section starts
|
||||||
|
hidden, `[show]` toggles to `[hide]`.
|
||||||
|
- `Esc` closes the modal; `ArrowDown` / `ArrowUp` walk visible rows
|
||||||
|
(after applying name filter).
|
||||||
|
- Click outside the `.modal-content` closes the modal.
|
||||||
|
- Open DevTools Network tab → reload; confirm one request to `/api/adults`
|
||||||
|
fires on page load (data is pre-warmed), no further requests on row
|
||||||
|
click.
|
||||||
|
4. Browser: `http://localhost:8080/juniors`
|
||||||
|
- Same checks; verify `expected = "?"` rows render the question-mark
|
||||||
|
status without throwing JS errors.
|
||||||
|
5. Cross-check against Python: `make web-py &` → `http://localhost:5001/adults`
|
||||||
|
should look visually equivalent (modulo nav styling) for the same fixture.
|
||||||
|
6. After commit: `tea pr create --base main --head feat/go-m6-5-modal-js`.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-5-modal-js` off `main`,
|
||||||
|
commit with `Co-Authored-By` trailer, push with `-u`, open MR via
|
||||||
|
`tea pr create`. Do not merge from CLI. After the user merges in Gitea:
|
||||||
|
|
||||||
|
- Add CHANGELOG entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||||
|
- Tick **M6.5** in
|
||||||
|
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:117](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L117)
|
||||||
|
with the merge SHA.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- **`/qr` modal** — separate `#qrModal` element, lives in M6.6 alongside
|
||||||
|
`/sync-bank`, `/flush-cache`, `/version`.
|
||||||
|
- **Pay buttons inside the modal** — Python's modal does not include
|
||||||
|
Pay-from-modal; current Go pages already render row-level Pay buttons
|
||||||
|
in M6.2/M6.3.
|
||||||
|
- **Modal on `/payments`** — Python `/payments` has no modal, neither does
|
||||||
|
Go (confirmed in M6.4 plan).
|
||||||
|
- **Deep-linking (`/adults#name=Foo`)** — not in Python, not adding.
|
||||||
|
- **Headless-browser JS tests** — out of scope for this milestone; manual
|
||||||
|
browser verification per §Verification.
|
||||||
124
docs/plans/2026-05-08-1334-go-m6-6-action-pages.md
Normal file
124
docs/plans/2026-05-08-1334-go-m6-6-action-pages.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# M6.6 — `/qr`, `/sync-bank`, `/flush-cache`, `/version` (Go parity)
|
||||||
|
|
||||||
|
> On approval, relocate this file to `docs/plans/YYYY-MM-DD-HHMM-go-m6-6-action-pages.md` per the repo's `CLAUDE.md` "Plans" convention before starting work.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go-rewrite progress tracker ([2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118)) lists M6.6 as:
|
||||||
|
|
||||||
|
> `/qr, /sync-bank, /flush-cache, /version pages`
|
||||||
|
|
||||||
|
After M6.4 (payments page) and M6.5 (member-detail modal JS), the only remaining pre-deployment HTML/action endpoints in Python that the Go binary doesn't yet implement are these four. The adults/juniors templates already produce `/qr?...` URLs via the `qrHref` / `qrHrefAll` helpers ([render.go:47-71](go/internal/web/render.go#L47-L71)) — the matching handler is the missing piece. `sync` and `flush_cache` templates exist as "Coming in M6.6" stubs and need real markup + working handlers. `/api/version` already serves build metadata; `/version` just needs to alias it (Python parity).
|
||||||
|
|
||||||
|
After M6.6 the M6 gate ("Browser smoke on :8080: all pages render … QR loads, sync/flush work end-to-end") is reachable.
|
||||||
|
|
||||||
|
## Confirmed design choices
|
||||||
|
|
||||||
|
- **`/sync-bank`**: Python-faithful — GET both renders and triggers the sync, captures stdout/stderr into a log buffer, surfaces success/failure via a flag. Matches existing nav `[Sync Bank Data]` link.
|
||||||
|
- **`/version`**: JSON alias of `/api/version`. No HTML page.
|
||||||
|
- **QR library**: `github.com/skip2/go-qrcode`. Default level `qrcode.Medium`, size 256.
|
||||||
|
|
||||||
|
## Scope per endpoint
|
||||||
|
|
||||||
|
### 1. `GET /qr` — PNG only
|
||||||
|
|
||||||
|
Match Python ([app.py:321-356](app.py#L321-L356)) verbatim semantics:
|
||||||
|
|
||||||
|
1. Read `account` (default `cfg.BankAccount`), `amount` (default `"0"`), `message` (default `""`).
|
||||||
|
2. Validate account against `^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`; on miss, silently fall back to `cfg.BankAccount`.
|
||||||
|
3. Parse amount as float64; clamp to `[0, 10_000_000]`; format `%.2f`; on parse error → `"0.00"`.
|
||||||
|
4. Truncate message to 60 runes; strip all `*`.
|
||||||
|
5. If account contains `/`, split into `{number}*BC:{bankcode}`; else use raw.
|
||||||
|
6. SPD payload: `SPD*1.0*ACC:{acc}*AM:{amount}*CC:CZK*MSG:{msg}` (no `BC:` key when IBAN — Python doesn't either).
|
||||||
|
7. `qrcode.Encode(payload, qrcode.Medium, 256)` → write bytes with `Content-Type: image/png`.
|
||||||
|
|
||||||
|
No template, no HTML.
|
||||||
|
|
||||||
|
### 2. `GET /sync-bank` — triggers + renders
|
||||||
|
|
||||||
|
Match Python ([app.py:124-151](app.py#L124-L151)):
|
||||||
|
|
||||||
|
1. Build a `*bytes.Buffer` to capture step output.
|
||||||
|
2. For each step write a header line then call:
|
||||||
|
- `banksync.SyncToSheets(ctx, cfg.PaymentsSheetID, fioClient, sheetsClient, banksync.SyncOpts{From: jan1ThisYear, To: dec31ThisYear, Sort: true})`
|
||||||
|
- `banksync.InferPayments(ctx, cfg.PaymentsSheetID, sheetsClient, sources, banksync.InferOpts{})`
|
||||||
|
- `cacheFlusher.Flush()` — append `"%d cache files deleted"`.
|
||||||
|
3. On any error: append `err.Error()` + a stack trace via `runtime/debug.Stack()`, set `success=false`, continue rendering.
|
||||||
|
4. Render `sync` template with `SyncPageData{ PageData; Output string; Success bool }`.
|
||||||
|
|
||||||
|
The `banksync` package's stdout-printing functions can be redirected by passing an `io.Writer` — confirm during implementation; if they only print to `os.Stdout`, capture by tee'ing through a `slog` handler or briefly swapping `os.Stdout` (or extend the package with a `*Writer` option). **Preferred**: add a `Logger io.Writer` field to `banksync.SyncOpts` / `InferOpts`. This is a minor extension to existing services, not a rewrite.
|
||||||
|
|
||||||
|
### 3. `/flush-cache` — GET form, POST action
|
||||||
|
|
||||||
|
Match Python ([app.py:117-122](app.py#L117-L122)):
|
||||||
|
|
||||||
|
- `GET` → render `flush_cache` with `Flushed=false`.
|
||||||
|
- `POST` → call `cacheFlusher.Flush()`, render with `Flushed=true, Deleted=n`.
|
||||||
|
|
||||||
|
### 4. `GET /version` — JSON
|
||||||
|
|
||||||
|
One-liner: register the existing `api.VersionHandler` (or its inline equivalent in [api/version.go](go/internal/web/api/version.go)) on the `/version` path as well as `/api/version`. Output: `{"tag", "commit", "build_date"}`.
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- **[go/internal/web/templates/qr.tmpl](go/internal/web/templates/qr.tmpl)** — *not actually needed*; remove from plan if `/qr` is PNG-only. (Decision: skip — no template.)
|
||||||
|
- Replace stub **[go/internal/web/templates/sync.tmpl](go/internal/web/templates/sync.tmpl)** with full markup: title, status banner colored by `Success`, `<pre>` of `Output`, "Run again" link.
|
||||||
|
- Replace stub **[go/internal/web/templates/flush_cache.tmpl](go/internal/web/templates/flush_cache.tmpl)** with: form (`POST /flush-cache` → `<button>[Flush Cache]</button>`), success banner when `Flushed`.
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- **[go/go.mod](go/go.mod)** / `go.sum` — add `github.com/skip2/go-qrcode`.
|
||||||
|
- **[go/internal/web/server.go](go/internal/web/server.go)** — register `GET /qr`, `GET /sync-bank` (replace stub), `GET /flush-cache` (form), `POST /flush-cache` (action), `GET /version`. Widen `Run(...)` signature to accept `fio.Client`, `sheets.Client`, and a `cache.Flusher` — these flow into a new `Deps` struct on `HTMLHandler`.
|
||||||
|
- **[go/internal/web/html_handler.go](go/internal/web/html_handler.go)** — add `ServeQR`, real `ServeSync`, split `ServeFlushCacheGET` / `ServeFlushCachePOST`, add `ServeVersion` (delegates to api package).
|
||||||
|
- **[go/internal/web/render.go](go/internal/web/render.go)** — add `SyncPageData{ PageData; Output string; Success bool }` and `FlushPageData{ PageData; Flushed bool; Deleted int }` view-models. (`pageNames` already includes `"sync"` and `"flush_cache"`; no change.)
|
||||||
|
- **[go/internal/web/qr.go](go/internal/web/qr.go)** *(new file)* — pure helper: `BuildSPD(account, amount, message string, defaultAccount string) (payload string)` + `RenderQRCode(payload string) ([]byte, error)`. Easier to unit-test than a method on the handler.
|
||||||
|
- **[go/internal/services/membership/loader.go](go/internal/services/membership/loader.go)** — add a small `CacheFlusher` interface (`Flush() (int, error)`); have `Sources` aggregate include it. *Alternative considered*: pass `*cache.FileCache` straight into `web.Run`. Choose whichever needs fewer test-stub edits — likely the direct-pass approach, since stubs in tests don't need cache behavior. **Decision: pass `cache.Flusher` directly into `web.Run` as a separate dependency, not through `Sources`.** Simpler, fewer interface changes.
|
||||||
|
- **[go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)** + `infer.go` — accept an optional `io.Writer` (in `SyncOpts.Logger` / `InferOpts.Logger`) and route progress prints through it; default to `os.Stdout` when nil for CLI parity.
|
||||||
|
- **[go/cmd/fuj/main.go](go/cmd/fuj/main.go)** — in the `server` subcommand, build `fio.Client`, `sheets.Client`, and `cache.Flusher` (already constructed inside `membership.NewSources` — expose it or construct a sibling) and pass them into `web.Run`.
|
||||||
|
|
||||||
|
## Reuse (already in the codebase)
|
||||||
|
|
||||||
|
- **Bank sync**: `banksync.SyncToSheets` ([go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)) and `banksync.InferPayments` ([go/internal/services/banksync/infer.go](go/internal/services/banksync/infer.go)) — same calls the CLI `sync` / `infer` subcommands use ([go/cmd/fuj/main.go:184](go/cmd/fuj/main.go#L184), [:222](go/cmd/fuj/main.go#L222)).
|
||||||
|
- **Cache flush**: `(*cache.FileCache).Flush() (int, error)` ([go/internal/io/cache/filecache.go:92](go/internal/io/cache/filecache.go#L92)) — already returns the deleted count.
|
||||||
|
- **Build metadata**: `BuildInfo{Version, Commit, BuildDate}` ([go/internal/web/server.go:28-35](go/internal/web/server.go#L28-L35)) is already plumbed everywhere; `/api/version` JSON shape is already correct ([go/internal/web/api/version.go](go/internal/web/api/version.go)).
|
||||||
|
- **Template render pattern**: `Renderer.Render(w, name, data)` ([go/internal/web/render.go:98-108](go/internal/web/render.go#L98-L108)).
|
||||||
|
- **Test scaffolding**: `web.NewRenderer()` + `httptest.NewRequest/NewRecorder` ([go/internal/web/html_handler_test.go:52-94](go/internal/web/html_handler_test.go#L52-L94)).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Add to `go/internal/web/html_handler_test.go` (and one new `qr_test.go` for the SPD builder):
|
||||||
|
|
||||||
|
- **`TestQRBuildSPD`** *(unit, in [go/internal/web/qr_test.go](go/internal/web/qr_test.go))* — table-driven: invalid account → fallback; `/`-account → `{num}*BC:{bc}`; IBAN → raw; amount `-1` → `0.00`; amount `99999999` → `9999999.00`; amount `"abc"` → `0.00`; message with `*` stripped; message length-clamped to 60.
|
||||||
|
- **`TestServeQR`** — request `/qr?account=…&amount=10&message=Hi`; assert 200, `Content-Type: image/png`, body starts with PNG magic `\x89PNG\r\n\x1a\n`.
|
||||||
|
- **`TestServeFlushCacheGet`** — assert form rendered, no banner.
|
||||||
|
- **`TestServeFlushCachePost`** — fixture flusher returns 3; assert banner says "3" and `Flushed`.
|
||||||
|
- **`TestServeSync`** — fixture banksync stubs return `(2, nil)` and `(1, nil)`, fixture flusher returns 4; assert page renders, status is success, output contains the section headers.
|
||||||
|
- **`TestServeVersion`** — assert `application/json`, body `{"tag":"v0","commit":"abc1234","build_date":"2026-01-01"}`.
|
||||||
|
- Extend the `TestHTMLHandlerSmoke` table to cover `/qr` and `/version`.
|
||||||
|
|
||||||
|
## Verification (end-to-end)
|
||||||
|
|
||||||
|
1. `cd go && go build ./... && go test ./...`
|
||||||
|
2. `cd go && go run ./cmd/fuj server` — open http://localhost:8080
|
||||||
|
3. Smoke:
|
||||||
|
- **/adults** → click `Pay` next to any unpaid month → modal shows QR image (this exercises `/qr`). Confirm a Czech banking app can scan it (visual check).
|
||||||
|
- Direct `/qr?account=2702008874/2010&amount=700&message=Test` → PNG renders.
|
||||||
|
- **/sync-bank** → click `[Sync Bank Data]` in nav → page shows "Syncing…", then sections + "Inferred Payments", "Cache Flushed", green status banner.
|
||||||
|
- **/flush-cache** → click `[Flush Cache]` → success banner with file count.
|
||||||
|
- **/version** in browser → JSON visible.
|
||||||
|
- Adults / juniors / payments still render (no regressions from `Run` signature change).
|
||||||
|
4. Run the parity diff: `go run ./cmd/parity` — `/api/version` must still match Python.
|
||||||
|
5. Append a CHANGELOG entry per `CLAUDE.md`; tick `M6.6` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118).
|
||||||
|
|
||||||
|
## Branch / MR
|
||||||
|
|
||||||
|
Per `CLAUDE.md` workflow: `feat/go-m6-6-action-pages` off `main`, push with `-u`, open with `tea pr create --base main --head feat/go-m6-6-action-pages`.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- Auth / CSRF protection on `/sync-bank` and `/flush-cache` — Python has none either; deferred to M8 hardening.
|
||||||
|
- M6.7 (single-binary embed verification) — separate ticket.
|
||||||
|
- Streaming progress for `/sync-bank` (currently buffered until completion, like Python).
|
||||||
|
- Configurable QR error-correction level / size.
|
||||||
335
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Normal file
335
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# M6.6.1 — Pay-button QR popup modal (`/adults`, `/juniors`)
|
||||||
|
|
||||||
|
> Plan-mode note: per project convention this plan should live at
|
||||||
|
> `docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md`. The first
|
||||||
|
> execution step after approval is to copy this file there.
|
||||||
|
> Branch: `feat/go-m6-6-1-payment-qr-modal` off `main`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M6.6 ([docs/plans/2026-05-08-1334-go-m6-6-action-pages.md](docs/plans/2026-05-08-1334-go-m6-6-action-pages.md))
|
||||||
|
landed the backend `/qr` PNG endpoint plus `/sync-bank`, `/flush-cache`,
|
||||||
|
`/version`. While doing it, the **client-side** half of the Pay flow was
|
||||||
|
collapsed from a modal-with-details into a plain `<a href="/qr?...">` —
|
||||||
|
clicking Pay now navigates the whole tab to the raw PNG.
|
||||||
|
|
||||||
|
The Python app instead opens an in-page `#qrModal` showing the QR image
|
||||||
|
*plus* labelled details (account, amount, message, title), then closes
|
||||||
|
the modal on outside-click or `[close]`
|
||||||
|
([templates/adults.html:631-648](templates/adults.html#L631-L648),
|
||||||
|
JS at [templates/adults.html:963-993](templates/adults.html#L963-L993)).
|
||||||
|
This is the UX the user expects from the dashboard — staying on the
|
||||||
|
table while displaying the QR is significantly more practical than
|
||||||
|
losing context to a fullscreen image.
|
||||||
|
|
||||||
|
M6.6.1 restores parity: the Pay/Pay-All buttons open an in-page
|
||||||
|
`#qrModal`, identical in structure to the Python original, driven by a
|
||||||
|
new vanilla-JS module `payment-qr.js` that mirrors the `member-detail.js`
|
||||||
|
convention from M6.5.
|
||||||
|
|
||||||
|
All the building blocks are already in place:
|
||||||
|
|
||||||
|
- **`/qr` endpoint** ships PNGs ([go/internal/web/qr.go](go/internal/web/qr.go), tested by [TestServeQR](go/internal/web/html_handler_test.go#L207-L231)).
|
||||||
|
- **CSS** for `#qrModal .modal-content`, `.qr-image`, `.qr-details` is
|
||||||
|
already in [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — lifted during M6.1.
|
||||||
|
- **Template helpers** `qrHref` / `qrHrefAll` ([go/internal/web/render.go:63-85](go/internal/web/render.go#L63-L85))
|
||||||
|
already produce the correct query string + Czech `MM/YYYY` format
|
||||||
|
for the `message=` param. They're useful for sanity (and for any
|
||||||
|
no-JS fallback) but become dead code post-M6.6.1 — see §Out of scope.
|
||||||
|
- **Bank account** is already exposed as `$.Data.BankAccount` to every
|
||||||
|
template that has a Pay button.
|
||||||
|
- **JS module convention**: `member-detail.js` is the M6.5 baseline —
|
||||||
|
IIFE, `'use strict'`, event delegation off `data-*` attributes, no
|
||||||
|
inline `onclick`, no globals leaked.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### 1. Replace `<a href="{{qrHref ...}}">` with `<button data-...>`
|
||||||
|
|
||||||
|
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
|
||||||
|
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl)
|
||||||
|
(both have identical Pay markup at lines 65 and 72):
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<button type="button" class="pay-btn"
|
||||||
|
data-name="{{$row.Name}}"
|
||||||
|
data-amount="{{$cell.Amount}}"
|
||||||
|
data-month="{{$cell.Month}}"
|
||||||
|
data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<button type="button" class="pay-btn"
|
||||||
|
data-name="{{$row.Name}}"
|
||||||
|
data-amount="{{$row.PayableAmount}}"
|
||||||
|
data-month="{{$row.UnpaidPeriods}}"
|
||||||
|
data-raw-month="{{$row.RawUnpaidPeriods}}"
|
||||||
|
data-pay-all="1">Pay All</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Why `<button type="button">` and not `<a>`: the click no longer
|
||||||
|
navigates — the JS handles it. `type="button"` prevents accidental form
|
||||||
|
submission. A no-JS fallback could be added later by re-introducing
|
||||||
|
`href="{{qrHref ...}}"` and letting the JS call `e.preventDefault()` —
|
||||||
|
deferred until anyone actually asks (see §Out of scope).
|
||||||
|
|
||||||
|
`data-month` carries the human-readable period (e.g. `January 2026`,
|
||||||
|
or `January 2026 + February 2026` for Pay-All) which the modal title
|
||||||
|
displays. `data-raw-month` carries the machine format (`2026-01`,
|
||||||
|
`2026-01+2026-02`) which the JS converts to `MM/YYYY` for the QR
|
||||||
|
message — same conversion Python's `showPayQR` does at
|
||||||
|
[templates/adults.html:966-968](templates/adults.html#L966-L968).
|
||||||
|
`data-pay-all` is purely informational; the JS doesn't actually need
|
||||||
|
it (the same code path handles single + multi-period), but keeping it
|
||||||
|
helps debugging and any future analytics.
|
||||||
|
|
||||||
|
### 2. Add `#qrModal` markup
|
||||||
|
|
||||||
|
Append after the `#memberModal` block in both `adults.tmpl` and
|
||||||
|
`juniors.tmpl` (around line 185 of adults, line 165 of juniors), mirroring
|
||||||
|
[templates/adults.html:631-648](templates/adults.html#L631-L648) but
|
||||||
|
using `class="active"` toggling (the convention M6.5 picked) instead of
|
||||||
|
`style.display = 'block'`:
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The CSS already targets `#qrModal .modal-content` directly. To make the
|
||||||
|
"hidden by default / visible when `.active`" toggle behave the same
|
||||||
|
way `#memberModal` does, the existing CSS rules for `#memberModal`
|
||||||
|
(`display:none`, `#memberModal.active { display: flex; }`) need a
|
||||||
|
sibling pair for `#qrModal`. Audit `app.css` during implementation:
|
||||||
|
- if `#qrModal` already has both rules, no change.
|
||||||
|
- if it has only `display:none`, add `#qrModal.active { display: flex; }`.
|
||||||
|
- if it has neither, add both.
|
||||||
|
|
||||||
|
This is a 2-line CSS adjustment at most; tracked under §Critical files.
|
||||||
|
|
||||||
|
### 3. Bank account on the page
|
||||||
|
|
||||||
|
The modal needs the bank account string for the `qrAccount` label and
|
||||||
|
for building the `/qr?account=…` URL. Two clean options:
|
||||||
|
|
||||||
|
**(a)** Read it from the `<button>`'s `data-bank-account` attribute.
|
||||||
|
Per-button duplication, but trivially done in templates and keeps the JS
|
||||||
|
module's surface tiny.
|
||||||
|
|
||||||
|
**(b)** Stamp it once on the page as `<body data-bank-account="…">`
|
||||||
|
(or on the existing `#filterContainer`).
|
||||||
|
|
||||||
|
**Decision: (b)** — single stamping site, follows the same pattern
|
||||||
|
M6.5 used for `data-page` on `#filterContainer`. Add
|
||||||
|
`data-bank-account="{{.Data.BankAccount}}"` to `#filterContainer` in
|
||||||
|
both templates (one-line edit each).
|
||||||
|
|
||||||
|
### 4. Create [go/internal/web/static/js/payment-qr.js](go/internal/web/static/js/payment-qr.js)
|
||||||
|
|
||||||
|
A new module loaded with `<script src="/static/js/payment-qr.js" defer></script>`
|
||||||
|
right after `member-detail.js` in both templates. Pure ES, no build
|
||||||
|
step, IIFE, `'use strict'`. Skeleton — port of Python's `showPayQR`
|
||||||
|
([templates/adults.html:963-986](templates/adults.html#L963-L986))
|
||||||
|
plus event wiring:
|
||||||
|
|
||||||
|
```js
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const bankAccount = container.dataset.bankAccount || '';
|
||||||
|
|
||||||
|
const modal = document.getElementById('qrModal');
|
||||||
|
const titleEl = document.getElementById('qrTitle');
|
||||||
|
const imgEl = document.getElementById('qrImg');
|
||||||
|
const accountEl = document.getElementById('qrAccount');
|
||||||
|
const amountEl = document.getElementById('qrAmount');
|
||||||
|
const messageEl = document.getElementById('qrMessage');
|
||||||
|
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||||
|
|
||||||
|
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges are converted piece-wise.
|
||||||
|
// Mirrors templates/adults.html:966-968.
|
||||||
|
function toCzechMonth(rawMonth) {
|
||||||
|
return rawMonth.split('+')
|
||||||
|
.map(p => p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'))
|
||||||
|
.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQR(name, amount, month, rawMonth) {
|
||||||
|
const numericMonth = toCzechMonth(rawMonth);
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
|
|
||||||
|
titleEl.innerText = `Payment for ${month}`;
|
||||||
|
accountEl.innerText = bankAccount;
|
||||||
|
amountEl.innerText = amount;
|
||||||
|
messageEl.innerText = message;
|
||||||
|
|
||||||
|
const url = '/qr?'
|
||||||
|
+ 'account=' + encodeURIComponent(bankAccount)
|
||||||
|
+ '&amount=' + encodeURIComponent(amount)
|
||||||
|
+ '&message=' + encodeURIComponent(message);
|
||||||
|
imgEl.src = url;
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQR() { modal.classList.remove('active'); }
|
||||||
|
|
||||||
|
// Event delegation: any .pay-btn click anywhere in the page.
|
||||||
|
document.addEventListener('click', ev => {
|
||||||
|
const btn = ev.target.closest('.pay-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
showQR(
|
||||||
|
btn.dataset.name,
|
||||||
|
btn.dataset.amount,
|
||||||
|
btn.dataset.month,
|
||||||
|
btn.dataset.rawMonth,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Event delegation** (single listener on `document`) handles both Pay
|
||||||
|
and Pay-All without enumerating buttons. It also survives any future
|
||||||
|
re-render of the table without needing rebinding.
|
||||||
|
- **Coexistence with `member-detail.js`**: `member-detail.js` listens
|
||||||
|
for `Escape` too, but only acts when `#memberModal` is active; the
|
||||||
|
new module similarly only acts when `#qrModal` is active. No collision.
|
||||||
|
- **Pre-warming**: nothing to pre-warm — `/qr` is a tiny on-demand PNG
|
||||||
|
and only fires when the user clicks.
|
||||||
|
- **Inline `onclick`** on `[close]` and outside-click in the markup are
|
||||||
|
carried over from the M6.5 modal style; consistent with `#memberModal`.
|
||||||
|
(Switching the project's modal close pattern to data attributes is a
|
||||||
|
separate cleanup, out of scope here.)
|
||||||
|
|
||||||
|
### 5. Tests
|
||||||
|
|
||||||
|
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
|
||||||
|
with markup-level checks. Headless-browser JS is out of scope (same
|
||||||
|
rationale as M6.5).
|
||||||
|
|
||||||
|
In `TestModalMarkup` (or a sibling test, e.g. `TestPaymentQRMarkup`):
|
||||||
|
|
||||||
|
- Body for `/adults` and `/juniors` contains
|
||||||
|
`<script src="/static/js/payment-qr.js"`.
|
||||||
|
- Body contains `id="qrModal"`, `id="qrImg"`, `id="qrAccount"`,
|
||||||
|
`id="qrAmount"`, `id="qrMessage"`, `id="qrTitle"`.
|
||||||
|
- The fixture row's Pay button is rendered as
|
||||||
|
`<button … class="pay-btn" … data-name="Test Member" data-amount="…" data-raw-month="2026-01"`.
|
||||||
|
- The fixture row's `.pay-btn` does **not** include `href=`.
|
||||||
|
- `#filterContainer` has `data-bank-account="CZ0000000000000000000000"`
|
||||||
|
(matching the test's `fixtureHandler` config).
|
||||||
|
|
||||||
|
### 6. Decide fate of `qrHref` / `qrHrefAll` template helpers
|
||||||
|
|
||||||
|
Once Pay/Pay-All buttons are JS-driven, the helpers in
|
||||||
|
[render.go:63-85](go/internal/web/render.go#L63-L85) have no callers.
|
||||||
|
**Decision: delete them**, plus their entries in `tmplFuncs`. A
|
||||||
|
no-JS fallback is not on the roadmap; if it ever becomes one, the
|
||||||
|
helpers are easy to resurrect from git.
|
||||||
|
|
||||||
|
The unit-test file currently has no direct tests for these helpers —
|
||||||
|
nothing to remove from tests.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
| Action | Path |
|
||||||
|
|--------|------|
|
||||||
|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — Pay/Pay-All → `<button data-…>`; add `#qrModal`; add `data-bank-account` on filter container; add `<script src="/static/js/payment-qr.js" defer>` |
|
||||||
|
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
|
||||||
|
| new | `go/internal/web/static/js/payment-qr.js` — modal module |
|
||||||
|
| edit | [go/internal/web/render.go](go/internal/web/render.go) — drop `qrHref`, `qrHrefAll`, and their `tmplFuncs` entries (and the `net/url`, `strconv` imports if no longer needed) |
|
||||||
|
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — verify / add `#qrModal { display:none; }` + `#qrModal.active { display:flex; }` rules |
|
||||||
|
| edit | [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go) — markup assertions |
|
||||||
|
|
||||||
|
No changes to: `assets.go` (`embed.FS` glob picks up new JS automatically),
|
||||||
|
`server.go`, `html_handler.go`, the `/qr` handler, `cmd/fuj/main.go`, the
|
||||||
|
API handlers, or any domain code.
|
||||||
|
|
||||||
|
## Reused existing infrastructure
|
||||||
|
|
||||||
|
- [go/internal/web/qr.go](go/internal/web/qr.go) — `/qr?account=…&amount=…&message=…` endpoint serves the PNG already
|
||||||
|
- [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — modal CSS already lifted whole
|
||||||
|
- [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js) — IIFE / `'use strict'` / `data-*` style to mirror
|
||||||
|
- [go/internal/web/api/types.go](go/internal/web/api/types.go) — `MemberRow.UnpaidPeriods` and `RawUnpaidPeriods` already populated for Pay-All
|
||||||
|
- [templates/adults.html:631-648,963-986](templates/adults.html#L631-L648) — Python reference for both modal markup and `showPayQR` JS (logic to port, not literal copy)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end, after copying the plan into `docs/plans/`:
|
||||||
|
|
||||||
|
1. `cd go && make go-build go-test go-lint` — all green.
|
||||||
|
2. `make web-go &` — boot Go on `:8080`.
|
||||||
|
3. Browser: `http://localhost:8080/adults`
|
||||||
|
- Click `Pay` on any unpaid month → `#qrModal` opens with
|
||||||
|
- title `Payment for <Month YYYY>`,
|
||||||
|
- `qrAccount` matching `BANK_ACCOUNT`,
|
||||||
|
- `qrAmount` matching the cell amount,
|
||||||
|
- `qrMessage` formatted as `<Name>: MM/YYYY`,
|
||||||
|
- QR image rendered from `/qr?…` (Network tab shows PNG response).
|
||||||
|
- Tab does **not** navigate; URL stays `/adults`.
|
||||||
|
- Click `Pay All` → modal opens with `+`-joined `MM/YYYY+MM/YYYY` message.
|
||||||
|
- Click outside the modal-content → modal closes.
|
||||||
|
- Click `[close]` → modal closes.
|
||||||
|
- Press `Esc` → modal closes.
|
||||||
|
- Open `[i]` member-detail modal first, then `Esc` → only that closes
|
||||||
|
(verify both modals don't fight each other).
|
||||||
|
4. Browser: `http://localhost:8080/juniors` — same checks. Verify Pay
|
||||||
|
on a `"?"`-expected row doesn't appear (Pay button is conditional on
|
||||||
|
`unpaid`/`partial` cells).
|
||||||
|
5. Cross-check against Python: `make web-py &` →
|
||||||
|
`http://localhost:5001/adults` modal looks visually equivalent for
|
||||||
|
the same fixture (modulo any nav / theme differences already accepted
|
||||||
|
in M6).
|
||||||
|
6. Scan a QR with a Czech banking app (or `zbarimg` on the saved PNG) —
|
||||||
|
payload starts with `SPD*1.0*ACC:` and the message decodes to
|
||||||
|
`<Name>: MM/YYYY`.
|
||||||
|
7. After the user merges in Gitea: append CHANGELOG entry (timestamp via
|
||||||
|
`date "+%Y-%m-%d %H:%M %Z"`); add a new tick line for **M6.6.1** in
|
||||||
|
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||||
|
immediately under the M6.6 line, in the same format as the other
|
||||||
|
sub-bullets.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-6-1-payment-qr-modal` off
|
||||||
|
`main`, commit with `Co-Authored-By` trailer, push with `-u`, open MR via
|
||||||
|
`tea pr create`. Do not merge or delete from CLI.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- **Pay button no-JS fallback** — feasible to keep `qrHref` and have
|
||||||
|
the JS `preventDefault` an `<a>`, but no current need. Resurrect the
|
||||||
|
helpers if asked.
|
||||||
|
- **Modal pattern unification** — `#memberModal` and `#qrModal` use
|
||||||
|
inline `onclick` for `[close]` and outside-click. Consistent with
|
||||||
|
M6.5 but worth a follow-up to delegate-event everything.
|
||||||
|
- **Pay-from-modal inside `#memberModal`** — Python doesn't have it, and
|
||||||
|
no one's asked for it on the Go side.
|
||||||
|
- **Per-button QR error-correction / size** — `/qr` is fixed at
|
||||||
|
Medium/256, same as M6.6. Configurable via query param can be added if
|
||||||
|
printing larger physical posters becomes a use case.
|
||||||
|
- **`/payments` Pay buttons** — the payments page is a ledger view; it
|
||||||
|
doesn't render Pay buttons in either backend.
|
||||||
184
docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md
Normal file
184
docs/plans/2026-05-08-1457-go-m6-7-single-binary-embed-verify.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# M6.7 — Single-binary embed verification
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M6.7 is the final task in M6 (Go-native HTML frontend). Per the
|
||||||
|
[progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md): "Wire
|
||||||
|
`embed.FS` into handlers; verify single-binary deployment includes all
|
||||||
|
assets."
|
||||||
|
|
||||||
|
The wiring is **already in place** from M6.1 onward:
|
||||||
|
|
||||||
|
- [go/internal/web/assets.go](../../go/internal/web/assets.go) declares
|
||||||
|
`//go:embed templates` → `templateFS` and `//go:embed static` → `staticFS`.
|
||||||
|
- [go/internal/web/render.go:66](../../go/internal/web/render.go#L66)
|
||||||
|
parses every page template via `template.New(...).ParseFS(templateFS, ...)`.
|
||||||
|
- [go/internal/web/server.go:48-68](../../go/internal/web/server.go#L48-L68)
|
||||||
|
serves `/static/*` via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
|
||||||
|
- [go/build/Dockerfile](../../go/build/Dockerfile) copies only the compiled
|
||||||
|
binary into the `alpine:3` runtime image — no `templates/` or `static/`
|
||||||
|
directory ever lands beside it.
|
||||||
|
|
||||||
|
What is missing is **proof** the embed is complete and stays complete:
|
||||||
|
|
||||||
|
1. Nothing fails the build/test if a contributor adds a new file under
|
||||||
|
`internal/web/templates/` or `internal/web/static/` that isn't matched
|
||||||
|
by an `//go:embed` glob (or, more realistically, adds a sibling
|
||||||
|
directory like `static/img/` and the glob still picks it up — but a
|
||||||
|
typo'd directive would silently drop it).
|
||||||
|
2. No automated test exercises the `/static/*` route against the embedded
|
||||||
|
FS — current tests in
|
||||||
|
[html_handler_test.go](../../go/internal/web/html_handler_test.go)
|
||||||
|
render templates (which proves `templateFS` is good) but never hit a
|
||||||
|
static URL through the mux.
|
||||||
|
3. The "single binary, no working-dir assets" property is undocumented —
|
||||||
|
if it ever broke, no one would notice until the Docker image started
|
||||||
|
500'ing in prod.
|
||||||
|
|
||||||
|
The intended outcome: a small test file plus a documented manual
|
||||||
|
verification step, after which M6.7 can be ticked and M6 closed.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
### 1. Add `go/internal/web/assets_test.go`
|
||||||
|
|
||||||
|
One new file, two tests, no production code changes.
|
||||||
|
|
||||||
|
**Test A — embed completeness regression guard.** Walks the on-disk
|
||||||
|
`templates/` and `static/` directories and asserts every regular file is
|
||||||
|
also present in the corresponding embedded FS. Catches:
|
||||||
|
|
||||||
|
- A new template added without updating the `//go:embed` directive
|
||||||
|
(current globs are `templates` and `static` — recursive by default for
|
||||||
|
directories, so this is a low-probability regression, but the test
|
||||||
|
doubles as living documentation of the contract).
|
||||||
|
- A typo in the directive (e.g. someone renames `static` → `assets` in
|
||||||
|
one place but not the other).
|
||||||
|
|
||||||
|
Implementation sketch:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestEmbedCompleteness(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
diskFS fs.FS // os.DirFS("templates") / os.DirFS("static")
|
||||||
|
embed fs.FS // exported helper or via internal test in package web
|
||||||
|
root string
|
||||||
|
}{...}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_ = fs.WalkDir(tc.diskFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() { return err }
|
||||||
|
embPath := tc.root + "/" + path
|
||||||
|
if _, err := fs.Stat(tc.embed, embPath); err != nil {
|
||||||
|
t.Errorf("file %q on disk but missing in embed.FS: %v", embPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `templateFS` and `staticFS` are unexported package vars, this
|
||||||
|
test lives in `package web` (not `web_test`) — sibling to
|
||||||
|
[assets.go](../../go/internal/web/assets.go). All the existing handler
|
||||||
|
tests are in `package web_test`; that's fine, this one is internal.
|
||||||
|
|
||||||
|
**Test B — `/static/*` end-to-end via the mux.** Builds an `http.ServeMux`
|
||||||
|
with the same wiring as
|
||||||
|
[server.go:68](../../go/internal/web/server.go#L68), fires httptest
|
||||||
|
requests, asserts:
|
||||||
|
|
||||||
|
- `GET /static/css/app.css` → 200, `Content-Type: text/css; charset=utf-8`,
|
||||||
|
body contains a known string from app.css (e.g. a CSS selector).
|
||||||
|
- `GET /static/js/member-detail.js` → 200, `Content-Type` starts with
|
||||||
|
`text/javascript` or `application/javascript`, body non-empty.
|
||||||
|
- `GET /static/js/payment-qr.js` → 200, body non-empty.
|
||||||
|
- `GET /static/css/missing.css` → 404 (sanity: the file server actually
|
||||||
|
rejects unknown paths instead of returning some default).
|
||||||
|
|
||||||
|
Rather than duplicate the mux assembly, factor a tiny helper (or test the
|
||||||
|
existing mux). The cleanest move: extract `staticHandler()` from
|
||||||
|
[server.go:48-50,68](../../go/internal/web/server.go#L48-L68) into a small
|
||||||
|
exported-from-package function or just `staticFS` / `fs.Sub` helper, and
|
||||||
|
have the test call it. Smallest delta: keep production code unchanged and
|
||||||
|
replicate the two-line wiring inside the test file (acceptable — it's
|
||||||
|
two lines and the test exists precisely to lock that contract).
|
||||||
|
|
||||||
|
### 2. Manual / one-shot verification (no code; documented in plan only)
|
||||||
|
|
||||||
|
Run once locally and tick M6.7. Command transcript:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make go-build # → ./bin/fuj
|
||||||
|
cp bin/fuj /tmp/fuj-standalone
|
||||||
|
cd /tmp # working dir has no templates/ or static/
|
||||||
|
./fuj-standalone server &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 1
|
||||||
|
curl -sf http://localhost:8080/adults | grep -q "Adults Dashboard"
|
||||||
|
curl -sf http://localhost:8080/juniors | grep -q "Juniors"
|
||||||
|
curl -sf http://localhost:8080/payments | grep -q "Payments Ledger"
|
||||||
|
curl -sf -o /tmp/app.css http://localhost:8080/static/css/app.css \
|
||||||
|
&& test -s /tmp/app.css
|
||||||
|
curl -sf -o /tmp/qr.js http://localhost:8080/static/js/payment-qr.js \
|
||||||
|
&& test -s /tmp/qr.js
|
||||||
|
kill $SERVER_PID
|
||||||
|
```
|
||||||
|
|
||||||
|
`fuj server` will fail to talk to Sheets without credentials, so the
|
||||||
|
`/adults` etc. pages will render with the `Error` field set — that's
|
||||||
|
fine; the assertion is that the **template + static asset pipeline** is
|
||||||
|
self-contained, not that data loads. Each curl above only checks for
|
||||||
|
markup present in every render path (header text and stylesheet body).
|
||||||
|
|
||||||
|
### 3. Tracker + changelog
|
||||||
|
|
||||||
|
- Tick `M6.7` in
|
||||||
|
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120](2026-05-03-2349-go-backend-rewrite-progress.md#L120),
|
||||||
|
append the merge SHA on the line.
|
||||||
|
- Mark "Last updated" date and bump milestone status: M6 complete, next is
|
||||||
|
M7.
|
||||||
|
- Append a `CHANGELOG.md` entry per CLAUDE.md convention (`date "+%Y-%m-%d %H:%M %Z"`).
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| [go/internal/web/assets_test.go](../../go/internal/web/assets_test.go) | **new** — two tests (embed completeness + `/static/*` mux) |
|
||||||
|
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) | tick M6.7, bump "last updated" |
|
||||||
|
| [CHANGELOG.md](../../CHANGELOG.md) | new top entry |
|
||||||
|
|
||||||
|
No production source files change. (If extracting the static handler
|
||||||
|
reads cleaner, a 4-line refactor in
|
||||||
|
[server.go](../../go/internal/web/server.go) is acceptable but optional.)
|
||||||
|
|
||||||
|
## Branch + MR
|
||||||
|
|
||||||
|
Per project convention this is a feature, so:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/go-m6-7-embed-verify
|
||||||
|
# … commits …
|
||||||
|
git push -u origin feat/go-m6-7-embed-verify
|
||||||
|
tea pr create --title "feat(go): M6.7 — single-binary embed verification" \
|
||||||
|
--description "<short body referencing M6.7>" --base main \
|
||||||
|
--head feat/go-m6-7-embed-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementation:
|
||||||
|
|
||||||
|
1. `make go-test` → green (new `TestEmbedCompleteness` and `TestStaticAssetsServed` pass).
|
||||||
|
2. `make go-lint` → clean.
|
||||||
|
3. Run the manual transcript in §2 above — all curls succeed, no
|
||||||
|
"template not found" or 404 on static assets.
|
||||||
|
4. `make go-build && docker build -f go/build/Dockerfile -t fuj-go:m6-7 go/` →
|
||||||
|
succeeds; `docker run --rm -p 8080:8080 fuj-go:m6-7` serves `/adults`
|
||||||
|
with stylesheet attached (visual smoke test in browser).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Re-architecting how templates are parsed or served.
|
||||||
|
- Compressing / fingerprinting static assets (a separate concern).
|
||||||
|
- Live integration test with real Sheets data — covered later in M7.
|
||||||
184
docs/plans/2026-05-11-2353-fill-first-multi-month-allocation.md
Normal file
184
docs/plans/2026-05-11-2353-fill-first-multi-month-allocation.md
Normal file
@@ -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.
|
||||||
269
docs/plans/2026-05-24-2120-multi-account-bank-sync.md
Normal file
269
docs/plans/2026-05-24-2120-multi-account-bank-sync.md
Normal file
@@ -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.
|
||||||
90
docs/plans/2026-06-08-1110-junior-expected-fix.md
Normal file
90
docs/plans/2026-06-08-1110-junior-expected-fix.md
Normal file
@@ -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.
|
||||||
146
docs/plans/2026-06-08-1118-months-to-show.md
Normal file
146
docs/plans/2026-06-08-1118-months-to-show.md
Normal file
@@ -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`.
|
||||||
112
docs/plans/2026-06-12-1927-gitops-pr-action.md
Normal file
112
docs/plans/2026-06-12-1927-gitops-pr-action.md
Normal file
@@ -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.)
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fuj-management/go/internal/services/banksync"
|
"fuj-management/go/internal/services/banksync"
|
||||||
"fuj-management/go/internal/services/membership"
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -82,9 +83,40 @@ func serverCmd(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fioClients := buildFioClients(cfg)
|
||||||
|
|
||||||
|
actions := web.ActionHandlers{
|
||||||
|
BankSync: func(ctx context.Context, out io.Writer) error {
|
||||||
|
yr := time.Now().Year()
|
||||||
|
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
|
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||||
|
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
|
||||||
|
banksync.SyncOpts{From: from, To: to, Sort: true})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sync: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "Synced %d new transaction(s).\n\n", n)
|
||||||
|
|
||||||
|
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||||
|
n, err = banksync.InferPayments(ctx, config.PaymentsSheetID, sheetsCli, sources, banksync.InferOpts{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("infer: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||||
|
|
||||||
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil {
|
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -165,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 != "" {
|
||||||
@@ -181,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)
|
||||||
@@ -242,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.26.1
|
|||||||
require (
|
require (
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/invopop/jsonschema v0.14.0
|
github.com/invopop/jsonschema v0.14.0
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
google.golang.org/api v0.278.0
|
google.golang.org/api v0.278.0
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP
|
|||||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -34,29 +56,39 @@ var CacheSheetMap = map[string]string{
|
|||||||
// Mirrors scripts/config.py.
|
// Mirrors 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const (
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -115,10 +115,11 @@ 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ type ExceptionLoader interface {
|
|||||||
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
|
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheFlusher can invalidate all cached data.
|
||||||
|
type CacheFlusher interface {
|
||||||
|
FlushCache() (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Sources is the aggregate interface required by ReconcileReport.
|
// Sources is the aggregate interface required by ReconcileReport.
|
||||||
type Sources interface {
|
type Sources interface {
|
||||||
AttendanceLoader
|
AttendanceLoader
|
||||||
TransactionLoader
|
TransactionLoader
|
||||||
ExceptionLoader
|
ExceptionLoader
|
||||||
|
CacheFlusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStubSources returns a Sources whose every method returns ErrIOPending.
|
// NewStubSources returns a Sources whose every method returns ErrIOPending.
|
||||||
@@ -52,3 +58,5 @@ func (stubSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction,
|
|||||||
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||||
return nil, ErrIOPending
|
return nil, ErrIOPending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (stubSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionK
|
|||||||
return f.exceptions, nil
|
return f.exceptions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|
||||||
func TestReconcileReport(t *testing.T) {
|
func TestReconcileReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
s := fakeSources{
|
s := fakeSources{
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ func (s *realSources) LoadTransactions(ctx context.Context) ([]reconcile.Transac
|
|||||||
return parseTransactionRows(rows)
|
return parseTransactionRows(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlushCache deletes all cached data files and resets in-memory cache state.
|
||||||
|
func (s *realSources) FlushCache() (int, error) { return s.cache.Flush() }
|
||||||
|
|
||||||
// LoadExceptions fetches the exceptions tab (cached).
|
// LoadExceptions fetches the exceptions tab (cached).
|
||||||
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||||
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
|
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type Handler struct {
|
|||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlushCache invalidates all cached data via the underlying Sources.
|
||||||
|
func (h *Handler) FlushCache() (int, error) { return h.Sources.FlushCache() }
|
||||||
|
|
||||||
// ServeVersion handles GET /api/version.
|
// ServeVersion handles GET /api/version.
|
||||||
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, VersionResponse{
|
writeJSON(w, VersionResponse{
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
93
go/internal/web/assets_test.go
Normal file
93
go/internal/web/assets_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEmbedCompleteness guards against a new template or static file being
|
||||||
|
// added to disk but missing from the embedded FS (e.g. a new directory that
|
||||||
|
// the //go:embed glob does not match).
|
||||||
|
func TestEmbedCompleteness(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
diskDir string
|
||||||
|
embedFS fs.FS
|
||||||
|
embedRoot string
|
||||||
|
}{
|
||||||
|
{"templates", "templates", templateFS, "templates"},
|
||||||
|
{"static", "static", staticFS, "static"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
diskFS := os.DirFS(tc.diskDir)
|
||||||
|
_ = fs.WalkDir(diskFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
embPath := tc.embedRoot + "/" + path
|
||||||
|
if _, statErr := fs.Stat(tc.embedFS, embPath); statErr != nil {
|
||||||
|
t.Errorf("file %q exists on disk but is missing from embed.FS (%v)", embPath, statErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaticAssetsServed verifies that /static/* is served from the embedded
|
||||||
|
// FS through the same mux wiring used in server.go, so a standalone binary
|
||||||
|
// with no adjacent static/ directory still delivers assets.
|
||||||
|
func TestStaticAssetsServed(t *testing.T) {
|
||||||
|
subFS, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fs.Sub static: %v", err)
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(subFS)))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
wantCT string
|
||||||
|
wantSnippet string
|
||||||
|
}{
|
||||||
|
{"/static/css/app.css", "text/css", "body {"},
|
||||||
|
{"/static/js/member-detail.js", "javascript", "Member-detail modal"},
|
||||||
|
{"/static/js/filters.js", "javascript", ""},
|
||||||
|
{"/static/js/payment-qr.js", "javascript", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s: status %d, want 200", tc.path, w.Code)
|
||||||
|
}
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, tc.wantCT) {
|
||||||
|
t.Errorf("GET %s: Content-Type %q, want it to contain %q", tc.path, ct, tc.wantCT)
|
||||||
|
}
|
||||||
|
if tc.wantSnippet != "" && !strings.Contains(w.Body.String(), tc.wantSnippet) {
|
||||||
|
t.Errorf("GET %s: body missing expected snippet %q", tc.path, tc.wantSnippet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: unknown path → 404 (file server doesn't fall through silently)
|
||||||
|
t.Run("missing-file", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/static/css/nonexistent.css", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("unknown static path: status %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"fuj-management/go/internal/web/api"
|
"fuj-management/go/internal/web/api"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTMLHandler serves the Go-native HTML frontend.
|
// HTMLHandler serves the Go-native HTML frontend.
|
||||||
@@ -10,11 +13,12 @@ type HTMLHandler struct {
|
|||||||
renderer *Renderer
|
renderer *Renderer
|
||||||
build BuildInfo
|
build BuildInfo
|
||||||
apiHandler *api.Handler
|
apiHandler *api.Handler
|
||||||
|
actions ActionHandlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTMLHandler constructs an HTMLHandler.
|
// NewHTMLHandler constructs an HTMLHandler.
|
||||||
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
|
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHandlers) *HTMLHandler {
|
||||||
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
|
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -62,10 +66,78 @@ func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
|
||||||
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
|
pd := PageData{Active: "sync", Build: h.build}
|
||||||
|
|
||||||
|
if h.actions.BankSync == nil {
|
||||||
|
h.renderer.Render(w, "sync", SyncPageData{
|
||||||
|
PageData: pd,
|
||||||
|
Output: "Bank sync is not configured.",
|
||||||
|
Success: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
success := true
|
||||||
|
if err := h.actions.BankSync(r.Context(), &buf); err != nil {
|
||||||
|
fmt.Fprintf(&buf, "\nError: %s\n\nStack trace:\n%s", err.Error(), debug.Stack())
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(&buf, "\n=== Flush Cache ===")
|
||||||
|
n, err := h.apiHandler.FlushCache()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(&buf, "flush error: %s\n", err.Error())
|
||||||
|
success = false
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&buf, "%d cache file(s) deleted.\n", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderer.Render(w, "sync", SyncPageData{
|
||||||
|
PageData: pd,
|
||||||
|
Output: buf.String(),
|
||||||
|
Success: success,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
|
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
|
||||||
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
|
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||||
|
PageData: PageData{Active: "flush", Build: h.build},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeFlushCachePOST handles POST /flush-cache: flushes and re-renders with count.
|
||||||
|
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, _ := h.apiHandler.FlushCache()
|
||||||
|
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||||
|
PageData: PageData{Active: "flush", Build: h.build},
|
||||||
|
Flushed: true,
|
||||||
|
Deleted: n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeQR handles GET /qr: generates and returns a Czech QR Platba PNG.
|
||||||
|
func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
account := q.Get("account")
|
||||||
|
amount := q.Get("amount")
|
||||||
|
message := q.Get("message")
|
||||||
|
if account == "" {
|
||||||
|
account = h.apiHandler.Config.QRAccount
|
||||||
|
}
|
||||||
|
if amount == "" {
|
||||||
|
amount = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := BuildSPD(account, amount, message, h.apiHandler.Config.QRAccount)
|
||||||
|
png, err := RenderQRCode(payload)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(png)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package web_test
|
package web_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/config"
|
"fuj-management/go/internal/config"
|
||||||
"fuj-management/go/internal/domain/reconcile"
|
"fuj-management/go/internal/domain/reconcile"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
"fuj-management/go/internal/web/api"
|
"fuj-management/go/internal/web/api"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -41,11 +44,13 @@ func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.Exception
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|
||||||
func fixtureHandler(t *testing.T) *api.Handler {
|
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"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +60,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
|||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
path string
|
path string
|
||||||
@@ -65,7 +70,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
|||||||
{"/juniors", h.ServeJuniors},
|
{"/juniors", h.ServeJuniors},
|
||||||
{"/payments", h.ServePayments},
|
{"/payments", h.ServePayments},
|
||||||
{"/sync-bank", h.ServeSync},
|
{"/sync-bank", h.ServeSync},
|
||||||
{"/flush-cache", h.ServeFlushCache},
|
{"/flush-cache", h.ServeFlushCacheGET},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -99,7 +104,7 @@ func TestAdultsPage(t *testing.T) {
|
|||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -127,13 +132,54 @@ func TestAdultsPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModalMarkup(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
page string
|
||||||
|
wantRow string // data-name present only when the page has rows
|
||||||
|
}{
|
||||||
|
{"/adults", h.ServeAdults, "adults", `data-name="Test Member"`},
|
||||||
|
{"/juniors", h.ServeJuniors, "juniors", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
tc.handler(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`data-page="` + tc.page + `"`,
|
||||||
|
`id="memberModal"`,
|
||||||
|
`/static/js/member-detail.js`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.wantRow != "" && !strings.Contains(body, tc.wantRow) {
|
||||||
|
t.Errorf("body missing info-icon row %q", tc.wantRow)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPaymentsPage(t *testing.T) {
|
func TestPaymentsPage(t *testing.T) {
|
||||||
renderer, err := web.NewRenderer()
|
renderer, err := web.NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
|
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -157,3 +203,181 @@ func TestPaymentsPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServeQR(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/qr?account=2702008874%2F2010&amount=700&message=Test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeQR(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
|
||||||
|
t.Errorf("Content-Type = %q, want image/png", ct)
|
||||||
|
}
|
||||||
|
// PNG magic bytes: \x89PNG\r\n\x1a\n
|
||||||
|
magic := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
|
||||||
|
body := w.Body.Bytes()
|
||||||
|
if len(body) < len(magic) || !bytes.Equal(body[:len(magic)], magic) {
|
||||||
|
t.Error("response body does not start with PNG magic bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeFlushCacheGET(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/flush-cache", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeFlushCacheGET(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "Flush Cache") {
|
||||||
|
t.Error("body missing Flush Cache heading")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "file(s) deleted") {
|
||||||
|
t.Error("GET should not show deleted count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeFlushCachePOST(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/flush-cache", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeFlushCachePOST(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
// fixtureSources.FlushCache returns 0
|
||||||
|
if !strings.Contains(body, "file(s) deleted") {
|
||||||
|
t.Error("POST should show deleted count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeSync(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
|
||||||
|
actions := web.ActionHandlers{
|
||||||
|
BankSync: func(_ context.Context, out io.Writer) error {
|
||||||
|
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||||
|
fmt.Fprintln(out, "Synced 3 new transaction(s).")
|
||||||
|
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||||
|
fmt.Fprintln(out, "Inferred 2 row(s).")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sync-bank", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeSync(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"Sync Bank Data",
|
||||||
|
"Synced 3 new transaction(s).",
|
||||||
|
"Inferred 2 row(s).",
|
||||||
|
"Flush Cache",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentQRMarkup(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"/adults", h.ServeAdults},
|
||||||
|
{"/juniors", h.ServeJuniors},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
tc.handler(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="qrModal"`,
|
||||||
|
`id="qrImg"`,
|
||||||
|
`id="qrTitle"`,
|
||||||
|
`id="qrAccount"`,
|
||||||
|
`id="qrAmount"`,
|
||||||
|
`id="qrMessage"`,
|
||||||
|
`/static/js/payment-qr.js`,
|
||||||
|
`data-bank-account="CZ0000000000000000000000"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay buttons must use <button>, never a bare href to /qr.
|
||||||
|
if strings.Contains(body, `href="/qr`) {
|
||||||
|
t.Error("body must not contain href=/qr (Pay buttons should be <button>, not <a>)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeVersion(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
fixtureHandler(t).ServeVersion(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
|
||||||
|
t.Fatalf("decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"tag", "commit", "build_date"} {
|
||||||
|
if _, ok := raw[key]; !ok {
|
||||||
|
t.Errorf("JSON response missing key %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
50
go/internal/web/qr.go
Normal file
50
go/internal/web/qr.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validAccount = regexp.MustCompile(`^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`)
|
||||||
|
|
||||||
|
// BuildSPD builds a Czech QR Platba SPD string, matching Python's qr_code handler.
|
||||||
|
// Invalid account falls back to defaultAccount.
|
||||||
|
// Amount is clamped to [0, 10_000_000]; non-numeric input becomes "0.00".
|
||||||
|
// Message is truncated to 60 runes and stripped of '*' characters.
|
||||||
|
func BuildSPD(account, amount, message, defaultAccount string) string {
|
||||||
|
if !validAccount.MatchString(account) {
|
||||||
|
account = defaultAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
var amtStr string
|
||||||
|
var f float64
|
||||||
|
if _, err := fmt.Sscanf(amount, "%f", &f); err != nil || f < 0 || f > 10_000_000 {
|
||||||
|
amtStr = "0.00"
|
||||||
|
} else {
|
||||||
|
amtStr = fmt.Sprintf("%.2f", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
if utf8.RuneCountInString(message) > 60 {
|
||||||
|
runes := []rune(message)
|
||||||
|
message = string(runes[:60])
|
||||||
|
}
|
||||||
|
message = strings.ReplaceAll(message, "*", "")
|
||||||
|
|
||||||
|
var accStr string
|
||||||
|
if parts := strings.SplitN(account, "/", 2); len(parts) == 2 {
|
||||||
|
accStr = parts[0] + "*BC:" + parts[1]
|
||||||
|
} else {
|
||||||
|
accStr = account
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("SPD*1.0*ACC:%s*AM:%s*CC:CZK*MSG:%s", accStr, amtStr, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderQRCode encodes payload as a PNG QR code (256×256, error correction Medium).
|
||||||
|
func RenderQRCode(payload string) ([]byte, error) {
|
||||||
|
return qrcode.Encode(payload, qrcode.Medium, 256)
|
||||||
|
}
|
||||||
91
go/internal/web/qr_test.go
Normal file
91
go/internal/web/qr_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQRBuildSPD(t *testing.T) {
|
||||||
|
const def = "2702008874/2010"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
account string
|
||||||
|
amount string
|
||||||
|
message string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "czech account",
|
||||||
|
account: "2702008874/2010",
|
||||||
|
amount: "700",
|
||||||
|
message: "Test Member: 01/2026",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:700.00*CC:CZK*MSG:Test Member: 01/2026",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IBAN account",
|
||||||
|
account: "CZ6508000000192000145399",
|
||||||
|
amount: "500",
|
||||||
|
message: "hi",
|
||||||
|
want: "SPD*1.0*ACC:CZ6508000000192000145399*AM:500.00*CC:CZK*MSG:hi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid account falls back to default",
|
||||||
|
account: "NOTANACCOUNT",
|
||||||
|
amount: "100",
|
||||||
|
message: "x",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty account falls back to default",
|
||||||
|
account: "",
|
||||||
|
amount: "0",
|
||||||
|
message: "",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative amount clamped to 0.00",
|
||||||
|
account: def,
|
||||||
|
amount: "-1",
|
||||||
|
message: "",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "amount over 10M clamped to 0.00",
|
||||||
|
account: def,
|
||||||
|
amount: "99999999",
|
||||||
|
message: "",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-numeric amount becomes 0.00",
|
||||||
|
account: def,
|
||||||
|
amount: "abc",
|
||||||
|
message: "",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "asterisks stripped from message",
|
||||||
|
account: def,
|
||||||
|
amount: "100",
|
||||||
|
message: "pay*now",
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:paynow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "message truncated to 60 runes",
|
||||||
|
account: def,
|
||||||
|
amount: "0",
|
||||||
|
message: strings.Repeat("á", 65),
|
||||||
|
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:" + strings.Repeat("á", 60),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := BuildSPD(tc.account, tc.amount, tc.message, def)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("\ngot: %s\nwant: %s", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PageData is the view model passed to every HTML template.
|
// PageData is the view model passed to every HTML template.
|
||||||
@@ -37,6 +35,20 @@ type PaymentsPageData struct {
|
|||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncPageData is the view model for the /sync-bank HTML page.
|
||||||
|
type SyncPageData struct {
|
||||||
|
PageData
|
||||||
|
Output string
|
||||||
|
Success bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushPageData is the view model for the /flush-cache HTML page.
|
||||||
|
type FlushPageData struct {
|
||||||
|
PageData
|
||||||
|
Flushed bool
|
||||||
|
Deleted int
|
||||||
|
}
|
||||||
|
|
||||||
// Renderer parses and executes HTML templates from the embedded FS.
|
// Renderer parses and executes HTML templates from the embedded FS.
|
||||||
type Renderer struct {
|
type Renderer struct {
|
||||||
tmpls map[string]*template.Template
|
tmpls map[string]*template.Template
|
||||||
@@ -44,36 +56,7 @@ type Renderer struct {
|
|||||||
|
|
||||||
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
||||||
|
|
||||||
// qrHref builds the /qr query URL for a single-month Pay button.
|
var tmplFuncs = template.FuncMap{}
|
||||||
// rawMonth is "YYYY-MM"; it is converted to "MM/YYYY" in the QR message.
|
|
||||||
func qrHref(account string, amount int, name, rawMonth string) string {
|
|
||||||
// Convert "YYYY-MM" → "MM/YYYY" to match Python's showPayQR JS.
|
|
||||||
if len(rawMonth) == 7 && rawMonth[4] == '-' {
|
|
||||||
rawMonth = rawMonth[5:] + "/" + rawMonth[:4]
|
|
||||||
}
|
|
||||||
msg := name + ": " + rawMonth
|
|
||||||
return "/qr?" + url.Values{
|
|
||||||
"account": {account},
|
|
||||||
"amount": {strconv.Itoa(amount)},
|
|
||||||
"message": {msg},
|
|
||||||
}.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
// qrHrefAll builds the /qr query URL for a Pay-All button.
|
|
||||||
// rawPeriods is the "+" -joined MM/YYYY string from MemberRow.RawUnpaidPeriods.
|
|
||||||
func qrHrefAll(account string, amount int, name, rawPeriods string) string {
|
|
||||||
msg := name + ": " + rawPeriods
|
|
||||||
return "/qr?" + url.Values{
|
|
||||||
"account": {account},
|
|
||||||
"amount": {strconv.Itoa(amount)},
|
|
||||||
"message": {msg},
|
|
||||||
}.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
var tmplFuncs = template.FuncMap{
|
|
||||||
"qrHref": qrHref,
|
|
||||||
"qrHrefAll": qrHrefAll,
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRenderer parses all templates from the embedded FS.
|
// NewRenderer parses all templates from the embedded FS.
|
||||||
// A parse failure should be treated as a startup-time fatal error.
|
// A parse failure should be treated as a startup-time fatal error.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/config"
|
"fuj-management/go/internal/config"
|
||||||
"fuj-management/go/internal/services/membership"
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web/api"
|
"fuj-management/go/internal/web/api"
|
||||||
"fuj-management/go/internal/web/middleware"
|
"fuj-management/go/internal/web/middleware"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,8 +20,16 @@ type BuildInfo struct {
|
|||||||
BuildDate string
|
BuildDate string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionHandlers holds function closures for side-effectful operations that
|
||||||
|
// require dependencies (fio, sheets) not present on the core API handler.
|
||||||
|
type ActionHandlers struct {
|
||||||
|
// BankSync runs sync+infer and writes a human-readable log to out.
|
||||||
|
// nil disables the /sync-bank action (renders an error instead).
|
||||||
|
BankSync func(ctx context.Context, out io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
// Run registers routes and starts the HTTP server on addr.
|
// Run registers routes and starts the HTTP server on addr.
|
||||||
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
|
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config, actions ActionHandlers) error {
|
||||||
renderer, err := NewRenderer()
|
renderer, err := NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("init templates: %w", err)
|
return fmt.Errorf("init templates: %w", err)
|
||||||
@@ -33,7 +43,7 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
}
|
}
|
||||||
hh := NewHTMLHandler(renderer, build, ah)
|
hh := NewHTMLHandler(renderer, build, ah, actions)
|
||||||
|
|
||||||
staticSubFS, err := fs.Sub(staticFS, "static")
|
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,13 +60,16 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
|||||||
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
|
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
|
||||||
mux.HandleFunc("GET /payments", hh.ServePayments)
|
mux.HandleFunc("GET /payments", hh.ServePayments)
|
||||||
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
|
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
|
||||||
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
|
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCacheGET)
|
||||||
|
mux.HandleFunc("POST /flush-cache", hh.ServeFlushCachePOST)
|
||||||
|
mux.HandleFunc("GET /qr", hh.ServeQR)
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||||
|
|
||||||
// JSON API routes
|
// JSON API routes
|
||||||
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
||||||
|
mux.HandleFunc("GET /version", ah.ServeVersion)
|
||||||
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
||||||
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
||||||
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
||||||
|
|||||||
@@ -442,6 +442,23 @@ tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* QR Modal styles */
|
/* QR Modal styles */
|
||||||
|
#qrModal {
|
||||||
|
display: none !important;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#qrModal.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
#qrModal .modal-content {
|
#qrModal .modal-content {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
|
|||||||
245
go/internal/web/static/js/member-detail.js
Normal file
245
go/internal/web/static/js/member-detail.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// Member-detail modal for /adults and /juniors pages.
|
||||||
|
// Fetches /api/<page> once on load, caches the response, renders on row click.
|
||||||
|
// Mirrors templates/adults.html JS (lines 718-993) but as a standalone module.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const page = container.dataset.page; // "adults" | "juniors"
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
let apiData = null;
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
|
// ── Data load ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (apiData) return apiData;
|
||||||
|
const r = await fetch('/api/' + page);
|
||||||
|
if (!r.ok) throw new Error('[member-detail] failed to fetch /api/' + page + ': ' + r.status);
|
||||||
|
apiData = await r.json();
|
||||||
|
return apiData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-warm on page load so first click is instant.
|
||||||
|
loadData().catch(function (err) { console.error(err); });
|
||||||
|
|
||||||
|
// ── Modal render ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function showMember(name) {
|
||||||
|
currentMemberName = name;
|
||||||
|
const data = (await loadData());
|
||||||
|
const member = data.member_data[name];
|
||||||
|
if (!member) return;
|
||||||
|
|
||||||
|
document.getElementById('modalMemberName').textContent = name;
|
||||||
|
document.getElementById('modalTier').textContent = 'Tier: ' + (member.tier || '-');
|
||||||
|
|
||||||
|
const monthLabels = data.month_labels || {};
|
||||||
|
const statusBody = document.getElementById('modalStatusBody');
|
||||||
|
statusBody.innerHTML = '';
|
||||||
|
|
||||||
|
const allTransactions = [];
|
||||||
|
const monthKeys = Object.keys(member.months || {}).sort().reverse();
|
||||||
|
|
||||||
|
monthKeys.forEach(function (m) {
|
||||||
|
const md = member.months[m];
|
||||||
|
const expected = md.expected; // int for adults; int or "?" for juniors
|
||||||
|
const paid = md.paid || 0;
|
||||||
|
const attendance = md.attendance_count || 0;
|
||||||
|
const originalExpected = md.original_expected;
|
||||||
|
const isUnknown = (expected === '?');
|
||||||
|
|
||||||
|
let status = '-';
|
||||||
|
let statusClass = '';
|
||||||
|
if (!isUnknown && (expected > 0 || paid > 0)) {
|
||||||
|
if (paid >= expected && expected > 0) {
|
||||||
|
status = paid + '/' + expected;
|
||||||
|
statusClass = 'cell-ok';
|
||||||
|
} else if (paid > 0) {
|
||||||
|
status = paid + '/' + expected;
|
||||||
|
} else {
|
||||||
|
status = '0/' + expected;
|
||||||
|
statusClass = 'cell-unpaid';
|
||||||
|
}
|
||||||
|
} else if (isUnknown) {
|
||||||
|
status = paid > 0 ? paid + '/?' : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
let expectedCell;
|
||||||
|
if (isUnknown) {
|
||||||
|
expectedCell = '?';
|
||||||
|
} else if (md.exception) {
|
||||||
|
expectedCell = '<span style="color: #ffaa00;" title="Overridden from ' + originalExpected + '">' + expected + '*</span>';
|
||||||
|
} else {
|
||||||
|
expectedCell = expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMonth = monthLabels[m] || m;
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML =
|
||||||
|
'<td style="color: #888;">' + displayMonth + '</td>' +
|
||||||
|
'<td style="text-align: center; color: #ccc;">' + attendance + '</td>' +
|
||||||
|
'<td style="text-align: center; color: #ccc;">' + expectedCell + '</td>' +
|
||||||
|
'<td style="text-align: center; color: #ccc;">' + paid + '</td>' +
|
||||||
|
'<td style="text-align: right;" class="' + statusClass + '">' + status + '</td>';
|
||||||
|
statusBody.appendChild(row);
|
||||||
|
|
||||||
|
if (md.transactions) {
|
||||||
|
md.transactions.forEach(function (tx) {
|
||||||
|
allTransactions.push(Object.assign({ month: m }, tx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exceptions
|
||||||
|
const exList = document.getElementById('modalExceptionList');
|
||||||
|
const exSection = document.getElementById('modalExceptionSection');
|
||||||
|
exList.innerHTML = '';
|
||||||
|
const exceptions = [];
|
||||||
|
monthKeys.forEach(function (m) {
|
||||||
|
if (member.months[m].exception) {
|
||||||
|
exceptions.push(Object.assign({ month: m }, member.months[m].exception));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (exceptions.length > 0) {
|
||||||
|
exSection.style.display = 'block';
|
||||||
|
exceptions.forEach(function (ex) {
|
||||||
|
const displayMonth = monthLabels[ex.month] || ex.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="tx-meta">' + displayMonth + '</div>' +
|
||||||
|
'<div class="tx-main"><span class="tx-amount" style="color: #ffaa00;">' + ex.amount + ' CZK</span></div>' +
|
||||||
|
'<div class="tx-msg">' + (ex.note || 'No details provided.') + '</div>';
|
||||||
|
exList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
exSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other transactions
|
||||||
|
const otherList = document.getElementById('modalOtherList');
|
||||||
|
const otherSection = document.getElementById('modalOtherSection');
|
||||||
|
otherList.innerHTML = '';
|
||||||
|
if (member.other_transactions && member.other_transactions.length > 0) {
|
||||||
|
otherSection.style.display = 'block';
|
||||||
|
member.other_transactions.forEach(function (tx) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="tx-meta">' + tx.date + ' | ' + (tx.purpose || 'Other') + '</div>' +
|
||||||
|
'<div class="tx-main">' +
|
||||||
|
'<span class="tx-amount" style="color: #66ccff;">' + tx.amount + ' CZK</span>' +
|
||||||
|
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="tx-msg">' + (tx.message || '') + '</div>';
|
||||||
|
otherList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
otherSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matched transactions (payment history)
|
||||||
|
const txList = document.getElementById('modalTxList');
|
||||||
|
txList.innerHTML = '';
|
||||||
|
if (allTransactions.length === 0) {
|
||||||
|
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
|
||||||
|
} else {
|
||||||
|
allTransactions.sort(function (a, b) { return b.date.localeCompare(a.date); });
|
||||||
|
allTransactions.forEach(function (tx) {
|
||||||
|
const displayMonth = monthLabels[tx.month] || tx.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="tx-meta">' + tx.date + ' | matched to ' + displayMonth + '</div>' +
|
||||||
|
'<div class="tx-main">' +
|
||||||
|
'<span class="tx-amount">' + tx.amount + ' CZK</span>' +
|
||||||
|
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="tx-msg">' + (tx.message || '') + '</div>';
|
||||||
|
txList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw payments — hidden by default; reset toggle on each open
|
||||||
|
const rawList = document.getElementById('modalRawList');
|
||||||
|
const rawToggle = document.getElementById('rawPaymentsToggle');
|
||||||
|
rawList.style.display = 'none';
|
||||||
|
rawToggle.textContent = '[show]';
|
||||||
|
rawList.innerHTML = '';
|
||||||
|
const rawRows = (data.raw_payments || {})[name] || [];
|
||||||
|
if (rawRows.length === 0) {
|
||||||
|
rawList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>';
|
||||||
|
} else {
|
||||||
|
rawRows.forEach(function (tx) {
|
||||||
|
const inferredNote = (tx.inferred_amount && tx.inferred_amount !== '' && tx.inferred_amount !== tx.amount)
|
||||||
|
? ' <span style="color:#888;">(inferred: ' + tx.inferred_amount + ')</span>'
|
||||||
|
: '';
|
||||||
|
const manualNote = tx.manual_fix ? ' <span style="color:#ffaa00;">[manual fix]</span>' : '';
|
||||||
|
const bankIdNote = tx.bank_id ? '<span style="color:#444;"> · bank_id: ' + tx.bank_id + '</span>' : '';
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="tx-meta">' + tx.date + ' | purpose: ' + (tx.purpose || '—') + manualNote + '</div>' +
|
||||||
|
'<div class="tx-main">' +
|
||||||
|
'<span class="tx-amount">' + tx.amount + ' CZK' + inferredNote + '</span>' +
|
||||||
|
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="tx-msg">' + (tx.message || '') + '</div>' +
|
||||||
|
'<div class="tx-meta">' + (tx.person || '') + bankIdNote + '</div>';
|
||||||
|
rawList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('memberModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raw-payments toggle ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleRawPayments(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const list = document.getElementById('modalRawList');
|
||||||
|
const link = document.getElementById('rawPaymentsToggle');
|
||||||
|
const hidden = list.style.display === 'none';
|
||||||
|
list.style.display = hidden ? 'block' : 'none';
|
||||||
|
link.textContent = hidden ? '[hide]' : '[show]';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Close + keyboard nav ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('memberModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateMember(direction) {
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr.member-row'));
|
||||||
|
const visible = rows.filter(function (r) { return r.style.display !== 'none'; });
|
||||||
|
const idx = visible.findIndex(function (r) { return r.dataset.name === currentMemberName; });
|
||||||
|
if (idx === -1) return;
|
||||||
|
const next = idx + direction;
|
||||||
|
if (next >= 0 && next < visible.length) {
|
||||||
|
showMember(visible[next].dataset.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wiring ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.querySelectorAll('.info-icon[data-name]').forEach(function (el) {
|
||||||
|
el.addEventListener('click', function (ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
showMember(el.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { closeModal(); return; }
|
||||||
|
const modal = document.getElementById('memberModal');
|
||||||
|
if (!modal.classList.contains('active')) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
|
||||||
|
});
|
||||||
|
}());
|
||||||
60
go/internal/web/static/js/payment-qr.js
Normal file
60
go/internal/web/static/js/payment-qr.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const bankAccount = container.dataset.bankAccount || '';
|
||||||
|
|
||||||
|
const modal = document.getElementById('qrModal');
|
||||||
|
const titleEl = document.getElementById('qrTitle');
|
||||||
|
const imgEl = document.getElementById('qrImg');
|
||||||
|
const accountEl = document.getElementById('qrAccount');
|
||||||
|
const amountEl = document.getElementById('qrAmount');
|
||||||
|
const messageEl = document.getElementById('qrMessage');
|
||||||
|
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||||
|
|
||||||
|
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges converted piece-wise.
|
||||||
|
// Mirrors templates/adults.html showPayQR logic.
|
||||||
|
function toCzechMonth(rawMonth) {
|
||||||
|
return rawMonth.split('+')
|
||||||
|
.map(function (p) { return p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'); })
|
||||||
|
.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQR(name, amount, month, rawMonth) {
|
||||||
|
var numericMonth = toCzechMonth(rawMonth);
|
||||||
|
var message = name + ': ' + numericMonth;
|
||||||
|
|
||||||
|
titleEl.innerText = 'Payment for ' + month;
|
||||||
|
accountEl.innerText = bankAccount;
|
||||||
|
amountEl.innerText = amount;
|
||||||
|
messageEl.innerText = message;
|
||||||
|
|
||||||
|
imgEl.src = '/qr?'
|
||||||
|
+ 'account=' + encodeURIComponent(bankAccount)
|
||||||
|
+ '&amount=' + encodeURIComponent(amount)
|
||||||
|
+ '&message=' + encodeURIComponent(message);
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQR() {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var btn = ev.target.closest('.pay-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
showQR(
|
||||||
|
btn.dataset.name,
|
||||||
|
btn.dataset.amount,
|
||||||
|
btn.dataset.month,
|
||||||
|
btn.dataset.rawMonth
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -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}}">
|
<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…">
|
||||||
@@ -56,20 +56,20 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range $row := .Data.Results}}
|
{{range $row := .Data.Results}}
|
||||||
<tr class="member-row" data-name="{{$row.Name}}">
|
<tr class="member-row" data-name="{{$row.Name}}">
|
||||||
<td class="member-name">{{$row.Name}}</td>
|
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
|
||||||
{{range $i, $cell := $row.Months}}
|
{{range $i, $cell := $row.Months}}
|
||||||
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
|
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
|
||||||
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
||||||
{{$cell.Text}}
|
{{$cell.Text}}
|
||||||
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||||
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
{{end}}
|
{{end}}
|
||||||
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||||
{{$row.Balance}}
|
{{$row.Balance}}
|
||||||
{{if gt $row.PayableAmount 0}}
|
{{if gt $row.PayableAmount 0}}
|
||||||
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -137,5 +137,71 @@
|
|||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<div id="memberModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modalMemberName">Member Name</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('memberModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status Summary</div>
|
||||||
|
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th style="text-align: center;">Att.</th>
|
||||||
|
<th style="text-align: center;">Expected</th>
|
||||||
|
<th style="text-align: center;">Paid</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="modalStatusBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Fee Exceptions</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Payment History</div>
|
||||||
|
<div id="modalTxList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">
|
||||||
|
Raw Payments
|
||||||
|
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
|
||||||
|
</div>
|
||||||
|
<div id="modalRawList" class="tx-list" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/filters.js" defer></script>
|
<script src="/static/js/filters.js" defer></script>
|
||||||
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
|
<script src="/static/js/payment-qr.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{{define "title"}}Flush Cache{{end}}
|
{{define "title"}}Flush Cache{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Flush Cache</h1>
|
<h1>Flush Cache</h1>
|
||||||
<p class="description">Coming in M6.6</p>
|
{{if .Flushed}}
|
||||||
|
<p class="status-ok">Cache flushed: {{.Deleted}} file(s) deleted.</p>
|
||||||
|
{{end}}
|
||||||
|
<p class="description">Deletes all cached data files so the next request fetches fresh data from Google Sheets.</p>
|
||||||
|
<form method="POST" action="/flush-cache">
|
||||||
|
<button type="submit" class="btn">Flush Cache</button>
|
||||||
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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}}">
|
<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…">
|
||||||
@@ -56,20 +56,20 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range $row := .Data.Results}}
|
{{range $row := .Data.Results}}
|
||||||
<tr class="member-row" data-name="{{$row.Name}}">
|
<tr class="member-row" data-name="{{$row.Name}}">
|
||||||
<td class="member-name">{{$row.Name}}</td>
|
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
|
||||||
{{range $i, $cell := $row.Months}}
|
{{range $i, $cell := $row.Months}}
|
||||||
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
|
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
|
||||||
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
||||||
{{$cell.Text}}
|
{{$cell.Text}}
|
||||||
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||||
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
{{end}}
|
{{end}}
|
||||||
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||||
{{$row.Balance}}
|
{{$row.Balance}}
|
||||||
{{if gt $row.PayableAmount 0}}
|
{{if gt $row.PayableAmount 0}}
|
||||||
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -117,5 +117,71 @@
|
|||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<div id="memberModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modalMemberName">Member Name</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('memberModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status Summary</div>
|
||||||
|
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th style="text-align: center;">Att.</th>
|
||||||
|
<th style="text-align: center;">Expected</th>
|
||||||
|
<th style="text-align: center;">Paid</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="modalStatusBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Fee Exceptions</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Payment History</div>
|
||||||
|
<div id="modalTxList" class="tx-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">
|
||||||
|
Raw Payments
|
||||||
|
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
|
||||||
|
</div>
|
||||||
|
<div id="modalRawList" class="tx-list" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/filters.js" defer></script>
|
<script src="/static/js/filters.js" defer></script>
|
||||||
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
|
<script src="/static/js/payment-qr.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
{{define "title"}}Sync Bank Data{{end}}
|
{{define "title"}}Sync Bank Data{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Sync Bank Data</h1>
|
<h1>Sync Bank Data</h1>
|
||||||
<p class="description">Coming in M6.6</p>
|
{{if .Output}}
|
||||||
|
{{if .Success}}
|
||||||
|
<p class="status-ok">Sync completed successfully.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="status-error">Sync failed — see log below.</p>
|
||||||
|
{{end}}
|
||||||
|
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
|
||||||
|
{{else}}
|
||||||
|
<p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
|
||||||
|
{{end}}
|
||||||
|
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ class TestMultiMonthAllocation(unittest.TestCase):
|
|||||||
self.assertEqual(int(months['2026-02']['paid']), 750)
|
self.assertEqual(int(months['2026-02']['paid']), 750)
|
||||||
self.assertEqual(result['credits'].get('Alice', 0), 500)
|
self.assertEqual(result['credits'].get('Alice', 0), 500)
|
||||||
|
|
||||||
def test_proportional_underpayment(self):
|
def test_underpayment_fills_earliest_first(self):
|
||||||
"""Payment < total expected → proportional split; sum of paid == payment amount."""
|
"""Payment < total expected → fill earliest months first, spill remainder to later."""
|
||||||
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
|
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
|
||||||
sorted_months = ['2026-02', '2026-03', '2026-04']
|
sorted_months = ['2026-02', '2026-03', '2026-04']
|
||||||
amount = 1250
|
amount = 1250
|
||||||
@@ -103,18 +103,28 @@ class TestMultiMonthAllocation(unittest.TestCase):
|
|||||||
result = reconcile(members, sorted_months, [tx])
|
result = reconcile(members, sorted_months, [tx])
|
||||||
months = result['members']['Alice']['months']
|
months = result['members']['Alice']['months']
|
||||||
|
|
||||||
paid_02 = months['2026-02']['paid']
|
# 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||||
paid_03 = months['2026-03']['paid']
|
self.assertAlmostEqual(months['2026-02']['paid'], 750, places=2)
|
||||||
paid_04 = months['2026-04']['paid']
|
self.assertAlmostEqual(months['2026-03']['paid'], 350, places=2)
|
||||||
|
self.assertAlmostEqual(months['2026-04']['paid'], 150, places=2)
|
||||||
|
# No CZK lost
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
months['2026-02']['paid'] + months['2026-03']['paid'] + months['2026-04']['paid'],
|
||||||
|
amount, places=2,
|
||||||
|
)
|
||||||
|
|
||||||
# All months should be partial (underpaid)
|
def test_fill_first_across_two_transactions(self):
|
||||||
self.assertLess(paid_02, 750)
|
"""Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
|
||||||
self.assertLess(paid_03, 350)
|
members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
|
||||||
self.assertLess(paid_04, 750)
|
sorted_months = ['2026-02', '2026-03']
|
||||||
# Sum must equal the original payment (no CZK lost)
|
tx1 = _tx('Matyáš', '2026-02', 200)
|
||||||
self.assertAlmostEqual(paid_02 + paid_03 + paid_04, amount, places=2)
|
tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
|
||||||
# 02 and 04 have equal expected → equal allocation
|
|
||||||
self.assertAlmostEqual(paid_02, paid_04, places=2)
|
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)
|
||||||
|
|
||||||
def test_single_month_unchanged(self):
|
def test_single_month_unchanged(self):
|
||||||
"""Single-month payment: full amount goes to that month (regression guard)."""
|
"""Single-month payment: full amount goes to that month (regression guard)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user