Compare commits

..
Author SHA1 Message Date
kacerr bc49a8286d Merge pull request 'feat(fees): junior rate for 2026-08 is 0' (#50) from feat/junior-fee-2026-08 into main
Build and Push / build (push) Successful in 10s
Build and Push / build-go (push) Successful in 1m54s
Reviewed-on: #50
2026-10-08 21:27:57 +02:00
kacerrandClaude Opus 5.5 21db863958 feat(fees): junior rate for 2026-08 is 0
- 2026-08 added to the junior rate table at 0 CZK, same as adults
- A single practice in a 0-rate month is 0 instead of "?", since a
  free month needs no manual review
- Mirrored in Go and legacy Python; new calculate_junior_fee parity
  fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:27:10 +02:00
kacerr 7d55eca189 Merge pull request 'feat(infer): messages starting with "other:" are not monthly fees' (#49) from feat/infer-other-purpose into main
Build and Push / build (push) Successful in 1m10s
Build and Push / build-go (push) Successful in 2m1s
Reviewed-on: #49
2026-10-08 12:40:29 +02:00
kacerrandClaude Opus 5.5 bbad19d822 feat(infer): messages starting with "other:" are not monthly fees
When the bank message starts with "other:" (case-insensitive),
inference does not look for a month, neither in the text nor from the
payment date. The trimmed message is written to Purpose, so reconcile
books the payment as an "other" transaction for the person instead of
a monthly fee. Messages containing "other:" elsewhere are unchanged.

- matching.IsOtherPurpose / is_other_purpose shared by inference and
  reconcile
- reconcile applies the rule to rows not yet processed by inference
- no person found: Purpose is still written, row shows as unmatched
- implemented in Go and legacy Python; four new parity fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:37:03 +02:00
kacerrandClaude Opus 5.5 294c40fa42 fix(go): show "Expected: ?" in junior tooltip for unknown fees
A junior month with one session has an unknown expected fee. When such
a month had a payment, the Go tooltip printed "Expected: 0" where
Python prints "Expected: ?", which made make parity fail on
/api/juniors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:37:03 +02:00
kacerr d5da993690 Merge pull request 'feat(go): scheduled bank sync (Go only)' (#48) from feat/scheduled-bank-sync into main
Reviewed-on: #48
2026-10-08 12:27:14 +02:00
kacerrandClaude Opus 5.5 2c2db60fe7 feat(go): scheduled bank sync (Go only)
The Go server now runs sync -> infer -> flush-cache by itself at the
times in SYNC_SCHEDULE (comma-separated HH:MM, default 04:00, "off"
disables), read in SYNC_TZ (default Europe/Prague). Nothing runs at
startup; an invalid value stops the server with an error.

- internal/scheduler: time-of-day parsing, Next, and the run loop
- banksync.Runner: one lock shared by /sync-bank and the scheduler so
  runs never overlap, plus the status of the last run
- footer shows the last sync since start and the next scheduled run
- binary embeds time/tzdata because the Alpine image has no zone data

Deliberately not implemented in the legacy Python app, which is being
phased out; recorded in CLAUDE.md under "Deliberately Go-only features".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:17:15 +02:00
kacerr 1d5a1c95d2 Merge pull request 'fix(sync): dashboard sync covers current year plus Nov-Dec of previous' (#47) from fix/sync-window-prev-year-overlap into main
Reviewed-on: #47
2026-10-08 12:05:16 +02:00
38 changed files with 1371 additions and 41 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## 2026-10-08 21:27 CEST — feat(fees): junior rate for 2026-08 is 0
- Junior monthly rate for 2026-08: 500 → 0 CZK, same as adults.
- A single practice in a 0-rate month is now 0 instead of `?`, since there is nothing to review in a free month. Other months are unchanged.
- Mirrored in `go/internal/domain/fees/junior.go` and `legacy/scripts/attendance.py`; two new parity fixtures for `calculate_junior_fee`.
## 2026-10-08 12:37 CEST — feat(infer): messages starting with "other:" are not monthly fees
- When the bank message starts with `other:` (case-insensitive), inference no longer looks for a month. It copies the trimmed message into Purpose, so the payment is linked to the person as an "other" transaction and not counted as a monthly fee. Messages that contain `other:` elsewhere are inferred as before.
- If no person is found, Purpose is still written and the row shows under unmatched for a manual fix.
- Reconcile applies the same rule to rows not yet processed by inference.
- Existing rows are not re-inferred; clear Person and Purpose on a row to reprocess it.
- Go and Python: `matching.IsOtherPurpose` / `is_other_purpose`, `InferTransactionDetails` / `infer_transaction_details`, reconcile fallback; four new parity fixtures.
- Also fixed (Go): junior cell tooltip showed `Expected: 0` instead of `Expected: ?` when a month with unknown expected fee had a payment.
## 2026-10-08 12:16 CEST — feat(go): scheduled bank sync (Go only)
- The Go server now runs the bank sync (sync → infer → flush cache) by itself at the times in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`, `off` disables), read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup; an invalid value stops the server at startup with an error.
- Manual `/sync-bank` and scheduled runs share one lock, so two syncs can no longer overlap; a second request gets "a sync is already running".
- The page footer shows the last sync since start (time, trigger, ok/FAILED) and the next scheduled run.
- The binary embeds the time zone database (`time/tzdata`), since the Alpine image has none.
- Deliberately not implemented in the legacy Python app, which is being phased out; recorded in `CLAUDE.md` under "Deliberately Go-only features".
- Key files: `go/internal/scheduler/`, `go/internal/services/banksync/runner.go`, `go/cmd/fuj/main.go`, `go/internal/config/config.go`, `go/internal/web/`.
## 2026-10-08 12:04 CEST — fix(sync): dashboard sync covers the current year plus Nov–Dec of the previous one
- `/sync-bank` now uses one shared default window in both implementations: 1 November of the previous year to 31 December of the current year. Payments dated around New Year are picked up in January; duplicates are still filtered by Sync ID.
+19 -3
View File
@@ -20,6 +20,12 @@ Until the Python version is retired, **every new feature and every behaviour cha
Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).
### Deliberately Go-only features
These exist only in the Go app on purpose. Do not port them to the legacy Python app, which is being phased out:
- **Scheduled bank sync** — the Go server runs sync → infer → flush-cache by itself at the times in `SYNC_SCHEDULE`. Python's multi-worker gunicorn setup would need a cross-process lock for this, which is not worth building in code that is going away. In Python the sync still runs only from `/sync-bank` or the CLI.
## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
@@ -104,10 +110,11 @@ Fio Bank API ──► bank sync ──► Google Sheets (payments
- `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`.
- `go/cmd/parity` — diffs the JSON API of the Go and Python servers.
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`, `SYNC_SCHEDULE`, `SYNC_TZ`).
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference).
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference; `Runner` serialises manual and scheduled runs).
- `go/internal/scheduler` — runs a job at fixed times of day; used by `fuj server` for the scheduled bank sync.
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
@@ -133,7 +140,7 @@ Tiers are set in column B of the attendance sheet:
### Fee calculation
- Adults: 0 sessions → 0, 1 session → 200 CZK (never more than the month's rate, so a 0-rate month is free), 2+ sessions → monthly rate (default 600 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required; 0 in a 0-rate month), 2+ → monthly rate from `JuniorFeeMonthlyRate` / `JUNIOR_MONTHLY_RATE` (default 500 CZK)
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
### Merged months
@@ -144,12 +151,21 @@ Tiers are set in column B of the attendance sheet:
Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST).
### Bank sync
- Manual: open `/sync-bank` (both apps) or run `fuj sync` + `fuj infer` / `make sync-year` + `make infer`.
- Scheduled (Go server only): at each time in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`; `off` disables) read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup. Manual and scheduled runs share one lock and never overlap. The page footer shows the last run since start and the next one.
- Both use the same window: 1 November of the previous year to 31 December of the current year; rows are de-duplicated by Sync ID.
- The schedule runs per process: keep the deployment at one replica, and note that a local `make web-go` with real credentials also syncs at the scheduled time unless `SYNC_SCHEDULE=off`.
### Payments sheet columns
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
`Person` and `Purpose` are written by the inference step (`fuj infer` / `infer_payments.py`) and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
**Non-fee payments ("other")**: a Purpose that starts with `other:` (case-insensitive) is attached to the person's "other transactions" and is not counted against any month. Inference produces this by itself when the bank message starts with `other:`: it copies the trimmed message into Purpose and does not look for a month (neither in the text nor from the date). A message that only contains `other:` later in the text is inferred the normal way. The shared check is `matching.IsOtherPurpose` / `is_other_purpose`.
### QR codes
`/qr?account=…&amount=…&message=…` generates a Czech QR Platba PNG (SPD format).
@@ -136,7 +136,9 @@ before new feature work starts. Companion to the Go rewrite tracker,
- `warmup_cache()` runs at import of `app.py`, so `make test` makes real
Drive and Sheets read calls and needs the credentials file.
- Moot if Python is retired.
- [ ] **T18** Set the time zone in the Go container.
- [ ] **T18** Set the time zone in the Go container. Partly addressed
2026-10-08: the binary now embeds the zone database and the scheduled sync
uses `SYNC_TZ`; the "current month" logic below is still open.
- The image has no time zone configured, so `time.Now()` is UTC and the
"current month" flips one to two hours late at month boundaries.
- The Python image sets `Europe/Prague`.
@@ -161,6 +163,12 @@ Plan: [2026-10-08-1103-api-parity-vs-months-to-show.md](2026-10-08-1103-api-pari
dropped leading zeros. New rows now carry a text marker; existing rows are
left as they are and are read correctly either way.
## Features added since the audit
- 2026-10-08 — Scheduled bank sync in the Go server, deliberately Go-only.
Plan: [2026-10-08-1216-scheduled-bank-sync.md](2026-10-08-1216-scheduled-bank-sync.md).
It also added a lock so two syncs cannot overlap (relevant to T8).
## Notes & decisions
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
@@ -0,0 +1,134 @@
# Scheduled bank sync in the Go server (Go only)
## Context
Bank synchronization only runs when someone opens `/sync-bank`; the payments
sheet is as fresh as the last click. The Go server should run the same
sync → infer → flush-cache sequence by itself on a configured schedule,
defaulting to once a day.
Decisions already made:
- **Go only.** Deliberately not implemented in the legacy Python app, which is
being phased out (gunicorn's multi-worker model would also need a
cross-process lock). This is recorded as an explicit exception to the dual
implementation rule.
- **Schedule format:** one or more times of day. On by default, once a day.
## Behaviour
- `SYNC_SCHEDULE` — comma-separated `HH:MM` times, default `04:00`.
`off` disables the scheduler. An invalid value makes `fuj server` exit at
startup with a clear error.
- `SYNC_TZ` — IANA zone the times are read in, default `Europe/Prague`.
The binary embeds the zone database (`import _ "time/tzdata"`), because the
Alpine image has none.
- At each scheduled time the server runs exactly what `/sync-bank` runs
(`banksync.DefaultWindow`, `SyncToSheets`, `InferPayments`, cache flush),
with a 10-minute timeout, and logs start, result and row counts via `slog`.
- Nothing runs at startup; a restart only recomputes the next run time.
- A scheduled run and a manual `/sync-bank` can no longer overlap: both go
through one mutex. If a sync is already running, the manual page shows
"a sync is already running" and a scheduled run is skipped with a log line.
(This also closes today's gap where two overlapping manual runs could append
the same rows.)
- The footer of every page shows the last sync since the server started:
time, trigger (scheduled / manual), ok or failed, and the next scheduled
run. Status is in memory only and resets on restart.
## Implementation
Branch `feat/scheduled-bank-sync` off `main`; MR via `tea`.
1. **Config** — `go/internal/config/config.go`
- Add `SyncSchedule []TimeOfDay` and `SyncLocation *time.Location` to
`Config`.
- Add `ParseSyncSchedule(s string) ([]TimeOfDay, error)` (`"off"` → empty,
sorted, de-duplicated, rejects anything not `HH:MM`) and a
`LoadSyncSchedule() (…, error)` reading `SYNC_SCHEDULE` / `SYNC_TZ`.
`Load()` keeps its signature; `serverCmd` calls the new loader and exits
on error.
- New `config_test.go` for the parser (first tests in this package).
2. **Scheduler** — new package `go/internal/scheduler`
- `Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time` —
pure; builds candidates with `time.Date` in `loc` for today and tomorrow
and returns the earliest one strictly after `now` (handles DST days).
- `Run(ctx, times, loc, logger, job func(context.Context))` — loop:
compute `Next`, wait on a timer or `ctx.Done()`, call `job`.
- Tests: `Next` table test (before/after today's time, multiple times,
midnight rollover, the two DST switch days in Europe/Prague); `Run` with
a cancelled context returns.
3. **Sync runner with lock and status** — new
`go/internal/services/banksync/runner.go`
- `Runner` wraps the existing sync+infer function (moved out of the
closure in `go/cmd/fuj/main.go`) with a `sync.Mutex` (`TryLock`) and
records `Status{Started, Finished, Trigger, Err}`.
- `Run(ctx, trigger string, out io.Writer) error` returns `ErrBusy` when a
run is in progress. `Last() (Status, bool)` for display.
- Tests with a fake job: status recorded, error recorded, second
concurrent call gets `ErrBusy`.
4. **Wiring** — `go/cmd/fuj/main.go`
- Add `import _ "time/tzdata"`.
- Build one `Runner`; `ActionHandlers.BankSync` calls
`runner.Run(ctx, "manual", out)` (signature unchanged, so
`TestServeSync` keeps working).
- If the schedule is non-empty, start `go scheduler.Run(...)` whose job
calls `runner.Run(ctx, "scheduled", &buf)` with a 10-minute timeout, then
`sources.FlushCache()`, and logs the outcome. Log the schedule and next
run at startup, or "scheduled sync disabled".
5. **Display** — `go/internal/web`
- `ActionHandlers` gains optional `SyncInfo func() string` (last + next run
as one line). `PageData` gains `SyncInfo string`; add a small
`h.page(active)` helper in `html_handler.go` and use it where
`PageData{Active: …, Build: h.build}` is built today.
- `templates/partials/footer.tmpl`: append the line when non-empty.
- `templates/sync.tmpl`: fix the description, which still says "current
year" (stale since the window change).
- Extend `html_handler_test.go`: footer shows the info when provided,
nothing extra when not.
6. **Docs and notes**
- `CLAUDE.md`: document `SYNC_SCHEDULE` / `SYNC_TZ` under the Go config
list and the commands/architecture text; under "Dual implementation
rule" add a "Deliberately Go-only" list with this feature and the reason.
- `legacy/app.py`: one comment above `sync_bank()` stating that scheduled
sync exists only in Go, on purpose.
- `CHANGELOG.md` entry; copy this plan to
`docs/plans/2026-10-08-HHMM-scheduled-bank-sync.md`.
- Add a note to the cleanup task list: T18 (time zone) is addressed for
the scheduler only; the "current month" logic still uses the process
clock.
No new dependencies. No Dockerfile change. No change to parity fixtures or
the JSON API.
## Deployment notes (for the MR description)
- On by default at 04:00 Europe/Prague. Set `SYNC_SCHEDULE=off` to disable.
- A locally running `make web-go` with real credentials will also sync at
that time.
- With more than one replica each pod would run its own schedule; the
deployment must stay at one replica (or disable the schedule on extras).
- Without `FIO_API_TOKEN_*` the sync scrapes the transparent-account pages,
as today.
## Verification
1. `make go-test-all` and `make go-lint` equivalent (`go vet`, `gofmt -l`);
`make test` to confirm Python is untouched.
2. Start `fuj server` with `SYNC_SCHEDULE=off` → log says disabled, footer
shows no schedule.
3. Start with an invalid value (`SYNC_SCHEDULE=25:00`) → exits with an error.
4. Live check: start with `SYNC_SCHEDULE` set two minutes ahead and watch the
log for the scheduled run and the footer for the status line. **This
performs one real sync** against the payments sheet (currently one pending
transaction would be appended) — the same thing a click on `/sync-bank`
does.
5. While that run is in progress, or with a test job, confirm a second
trigger reports "already running".
6. Build the Go image and confirm the schedule log line shows the Prague
zone inside the container (proves the embedded zone data works).
@@ -0,0 +1,109 @@
# Inference: messages starting with "other:" are not monthly fees
## Context
Some payments are not monthly fees (tournament fees, jerseys, …). Reconcile
already supports them: a row whose **Purpose** starts with `other:` is attached
to the person's "other transactions" and is not counted against any month.
Today that only happens when someone types the purpose by hand, because
inference always tries to find a month — from the message, or failing that
from the payment date.
New rule: when the bank message for the recipient **starts with `other:`**,
inference must not look for a month. It copies the message into Purpose, so
the payment is linked to the person but not counted as a monthly fee. Messages
that merely contain `other:` somewhere else are handled the old way.
Implemented in both Go and legacy Python (this is inference behaviour, so the
dual implementation rule applies).
## Behaviour
- Trigger: message, after trimming whitespace, starts with `other:`
(case-insensitive, same test reconcile already uses for Purpose).
- Purpose written to the sheet: the trimmed message, unchanged
(e.g. `other: turnaj Praha`). No month parsing, no date fallback.
- Person: inferred exactly as today (sender + message + VS, then sender
alone), including the `[?]` review marker.
- Inferred Amount: the full amount, as today.
- No person found: Purpose is still written, Person stays empty. The row then
shows up under "unmatched" for a manual fix instead of being guessed again
on every run.
- Rows not yet processed by inference (Person and Purpose both empty) are
inferred on the fly by reconcile; that path follows the same rule, so such a
payment is never briefly counted as a monthly fee.
- Existing rows are untouched: inference only fills rows where manual fix,
Person and Purpose are all empty. To reprocess an old row, clear its Person
and Purpose cells.
## Implementation
Branch `feat/infer-other-purpose` off `main`; MR via `tea`.
The output shape of the inference function does not change: for an "other"
message the months list holds the single purpose string. That is the same
convention reconcile already uses internally (`matchedMonths = [purpose]`),
and it means the sheet-writing code needs no change in either language.
1. **Shared predicate**
- Go: `matching.IsOtherPurpose(s string) bool` in
`go/internal/domain/matching/infer.go`
(`strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")`).
- Python: `is_other_purpose(s)` in `legacy/scripts/match_payments.py`.
- Replace the two existing inline checks in reconcile with it
(`go/internal/domain/reconcile/reconcile.go`, `reconcile()` in
`match_payments.py`).
2. **Inference**
- Go `matching.InferTransactionDetails` and Python
`infer_transaction_details`: after member matching, if the message is an
other-purpose, return `months = [trimmed message]` and skip
`ParseMonthReferences` and the date fallback. Update the doc comments
(months may hold one `other:…` purpose).
- `banksync.InferPayments` (`go/internal/services/banksync/infer.go`) and
`infer_payments.py`: no logic change — they already join months into
Purpose and write the row when either members or months are non-empty.
3. **Reconcile fallback** (rows with empty Person/Purpose)
- In the inference-fallback branch of both reconcile implementations: if
the inferred months are a single other-purpose, set the row's purpose to
it and mark it as other, so it goes to the member's other-transactions
ledger instead of the month allocation.
4. **Tests and fixtures**
- Seeds in `legacy/scripts/_fixture_seeds.py`, captured with the existing
capture/scrub scripts for these two functions only:
- `infer_transaction_details`: `other_prefix` (message
`other: tournament`, member in sender → that purpose, no month),
`other_prefix_uppercase` (`Other: …`), `other_not_at_start`
(`leden other: x` → old behaviour, month parsed).
- `reconcile`: `11_other_message_fallback` (empty Person/Purpose, message
starts with `other:` → lands in `other_transactions`, month stays
unpaid).
- Go unit tests: `IsOtherPurpose` table; cases in
`matching/infer_test.go`; a case in `banksync/infer_test.go` asserting
the written row (`Person`, `Purpose = "other: …"`, amount) and one with
no matching member; a fallback case in `reconcile/reconcile_test.go`.
- Python: cases in `legacy/tests/test_match_payments.py` for
`infer_transaction_details` and the reconcile fallback.
- Existing fixtures must stay byte-identical apart from the new files.
5. **Docs**
- `CLAUDE.md`: add the rule to the payments-sheet / inference description.
- `CHANGELOG.md` entry; copy this plan to
`docs/plans/2026-10-08-HHMM-infer-other-purpose.md`.
No change to the JSON API, schemas, templates or the sheet layout.
## Verification
1. `make go-test-all` (unit + parity, including the new fixtures) and
`make test`.
2. `git status go/tests/fixtures` shows only the four new fixture files.
3. Dry run against the live sheet in both implementations
(`fuj infer --dry-run`, Python `infer_payments.py --dry-run`): confirm it
runs cleanly and proposes nothing unexpected. The live sheet probably has
no un-inferred row starting with `other:`, so the new rule itself is
proven by the tests rather than by live data.
4. `make parity` between the two servers (or the two-container run used
earlier) still reports 3/3 routes matching.
+80 -2
View File
@@ -1,20 +1,25 @@
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/io/fio"
"fuj-management/go/internal/io/sheets"
"fuj-management/go/internal/logging"
"fuj-management/go/internal/scheduler"
"fuj-management/go/internal/services/banksync"
"fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web"
"io"
"log/slog"
"os"
"strings"
"time"
_ "time/tzdata" // the container image has no zone database; SYNC_TZ needs one
)
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
@@ -90,8 +95,16 @@ func serverCmd(args []string) {
}
fioClients := buildFioClients(cfg)
actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error {
syncTimes, syncLoc, err := config.LoadSyncSchedule()
if err != nil {
fmt.Fprintf(os.Stderr, "fuj server: %v\n", err)
os.Exit(2)
}
// One runner serves both the /sync-bank page and the scheduler, so the two
// can never run at the same time.
runner := banksync.NewRunner(
func(ctx context.Context, out io.Writer) error {
from, to := banksync.DefaultWindow(time.Now())
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
@@ -110,6 +123,22 @@ func serverCmd(args []string) {
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
return nil
},
)
actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error {
return runner.Run(ctx, "manual", out)
},
SyncInfo: func() string { return syncInfo(runner, syncTimes, syncLoc, time.Now()) },
}
if len(syncTimes) == 0 {
logger.Info("scheduled bank sync disabled (SYNC_SCHEDULE=off)")
} else {
logger.Info("scheduled bank sync enabled", "times", fmt.Sprint(syncTimes), "tz", syncLoc.String())
go scheduler.Run(ctx, syncTimes, syncLoc, logger, func(ctx context.Context) {
scheduledSync(ctx, logger, runner, sources)
})
}
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
@@ -120,6 +149,55 @@ func serverCmd(args []string) {
}
}
// scheduledSyncTimeout bounds one scheduled run (two bank fetches plus sheet writes).
const scheduledSyncTimeout = 10 * time.Minute
// scheduledSync is the scheduler's job: the same sync → infer → flush-cache
// sequence as the /sync-bank page, with the outcome written to the log.
func scheduledSync(ctx context.Context, logger *slog.Logger, runner *banksync.Runner, sources membership.Sources) {
ctx, cancel := context.WithTimeout(ctx, scheduledSyncTimeout)
defer cancel()
logger.Info("scheduled bank sync starting")
var buf bytes.Buffer
err := runner.Run(ctx, "scheduled", &buf)
if errors.Is(err, banksync.ErrBusy) {
logger.Warn("scheduled bank sync skipped: a sync is already running")
return
}
output := strings.Join(strings.Fields(buf.String()), " ")
if _, ferr := sources.FlushCache(); ferr != nil {
logger.Error("scheduled bank sync: cache flush failed", "err", ferr)
}
if err != nil {
logger.Error("scheduled bank sync failed", "err", err, "output", output)
return
}
logger.Info("scheduled bank sync finished", "output", output)
}
// syncInfo renders the footer line: the last run since the server started
// and the next scheduled one.
func syncInfo(runner *banksync.Runner, times []scheduler.TimeOfDay, loc *time.Location, now time.Time) string {
const layout = "2006-01-02 15:04"
var parts []string
if st, ok := runner.Last(); ok {
result := "ok"
if st.Err != nil {
result = "FAILED"
}
parts = append(parts, fmt.Sprintf("last sync %s (%s) %s", st.Finished.In(loc).Format(layout), st.Trigger, result))
} else {
parts = append(parts, "no sync since start")
}
if next := scheduler.Next(now, times, loc); !next.IsZero() {
parts = append(parts, "next "+next.Format(layout))
} else {
parts = append(parts, "scheduled sync off")
}
return strings.Join(parts, " | ")
}
func feesCmd(args []string) {
fs := flag.NewFlagSet("fees", flag.ExitOnError)
fs.Usage = func() {
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"context"
"errors"
"fuj-management/go/internal/scheduler"
"fuj-management/go/internal/services/banksync"
"io"
"strings"
"testing"
"time"
)
func TestSyncInfo(t *testing.T) {
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
four := []scheduler.TimeOfDay{{Hour: 4}}
ok := banksync.NewRunner(func(context.Context, io.Writer) error { return nil })
if got := syncInfo(ok, four, time.UTC, now); got != "no sync since start | next 2026-10-09 04:00" {
t.Errorf("before any run: %q", got)
}
if err := ok.Run(context.Background(), "scheduled", io.Discard); err != nil {
t.Fatal(err)
}
got := syncInfo(ok, nil, time.UTC, now)
if !strings.HasPrefix(got, "last sync ") || !strings.HasSuffix(got, "(scheduled) ok | scheduled sync off") {
t.Errorf("after ok run, schedule off: %q", got)
}
bad := banksync.NewRunner(func(context.Context, io.Writer) error { return errors.New("boom") })
_ = bad.Run(context.Background(), "manual", io.Discard)
if got := syncInfo(bad, four, time.UTC, now); !strings.Contains(got, "(manual) FAILED | next 2026-10-09 04:00") {
t.Errorf("after failed run: %q", got)
}
}
+24
View File
@@ -1,6 +1,8 @@
package config
import (
"fmt"
"fuj-management/go/internal/scheduler"
"os"
"strconv"
"strings"
@@ -92,6 +94,28 @@ func Load() Config {
}
}
// Scheduled bank sync defaults (Go server only; the legacy Python app has no scheduler).
const (
DefaultSyncSchedule = "04:00" // SYNC_SCHEDULE: comma-separated HH:MM times, or "off"
DefaultSyncTimezone = "Europe/Prague" // SYNC_TZ: IANA zone the schedule is read in
)
// LoadSyncSchedule reads SYNC_SCHEDULE and SYNC_TZ. An empty schedule means
// the scheduled sync is disabled. Resolving the zone needs a zone database:
// the server binary embeds one via time/tzdata.
func LoadSyncSchedule() ([]scheduler.TimeOfDay, *time.Location, error) {
times, err := scheduler.ParseTimes(env("SYNC_SCHEDULE", DefaultSyncSchedule))
if err != nil {
return nil, nil, fmt.Errorf("SYNC_SCHEDULE: %w", err)
}
tz := env("SYNC_TZ", DefaultSyncTimezone)
loc, err := time.LoadLocation(tz)
if err != nil {
return nil, nil, fmt.Errorf("SYNC_TZ: unknown time zone %q", tz)
}
return times, loc, nil
}
// IBANAccountNum extracts the bare account number from a Czech IBAN.
// "CZ8520100000002800359168" → "2800359168"
// Structure: CZ(2 check)(4 bank code)(16 zero-padded account).
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"testing"
)
func TestLoadSyncSchedule(t *testing.T) {
cases := []struct {
name, schedule, tz string
wantTimes int
wantZone string
wantErr bool
}{
{"defaults", "", "", 1, "Europe/Prague", false},
{"two times", "06:00,18:00", "", 2, "Europe/Prague", false},
{"off", "off", "", 0, "Europe/Prague", false},
{"other zone", "04:00", "UTC", 1, "UTC", false},
{"bad time", "25:00", "", 0, "", true},
{"bad zone", "04:00", "Mars/Olympus", 0, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SYNC_SCHEDULE", tc.schedule)
t.Setenv("SYNC_TZ", tc.tz)
times, loc, err := LoadSyncSchedule()
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr {
return
}
if len(times) != tc.wantTimes {
t.Errorf("times = %v, want %d entries", times, tc.wantTimes)
}
if loc.String() != tc.wantZone {
t.Errorf("zone = %s, want %s", loc, tc.wantZone)
}
})
}
t.Setenv("SYNC_SCHEDULE", "")
t.Setenv("SYNC_TZ", "")
if times, _, _ := LoadSyncSchedule(); len(times) != 1 || times[0].String() != DefaultSyncSchedule {
t.Errorf("default schedule = %v, want [%s]", times, DefaultSyncSchedule)
}
}
+8 -5
View File
@@ -7,6 +7,7 @@ const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in
var JuniorFeeMonthlyRate = map[string]int{
"2025-09": 250,
"2026-03": 250,
"2026-08": 0,
}
// Expected is the result of a junior fee calculation.
@@ -21,17 +22,19 @@ type Expected struct {
// the given monthKey (format "YYYY-MM").
//
// 0 practices → Expected{Value: 0}
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?")
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?"),
// or Expected{Value: 0} when the month's rate is 0
// 2+ → Expected{Value: JuniorFeeMonthlyRate[monthKey] or JuniorFeeDefault}
func CalculateJuniorFee(attendanceCount int, monthKey string) Expected {
if attendanceCount == 0 {
return Expected{Value: 0}
}
if attendanceCount == 1 {
rate, ok := JuniorFeeMonthlyRate[monthKey]
if !ok {
rate = JuniorFeeDefault
}
if attendanceCount == 1 && rate > 0 {
return Expected{Unknown: true}
}
if rate, ok := JuniorFeeMonthlyRate[monthKey]; ok {
return Expected{Value: rate}
}
return Expected{Value: JuniorFeeDefault}
}
+3
View File
@@ -33,6 +33,9 @@ func TestCalculateJuniorFee(t *testing.T) {
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
// A 0-rate month is free, and a single practice needs no manual review.
{"two in free month is free", 2, "2026-08", Expected{Value: 0}},
{"single in free month is free", 1, "2026-08", Expected{Value: 0}},
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault.
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
+25 -2
View File
@@ -3,6 +3,7 @@ package matching
import (
"fmt"
"fuj-management/go/internal/domain/czech"
"strings"
"time"
)
@@ -17,12 +18,22 @@ type Transaction struct {
}
// InferredDetails is the result of InferTransactionDetails.
// Months holds "YYYY-MM" keys, or exactly one "other:…" purpose when the
// payment is not a monthly fee (see IsOtherPurpose).
type InferredDetails struct {
Members []Match
Months []string
SearchText string
}
// IsOtherPurpose reports whether s marks a payment that is not a monthly fee:
// it starts with "other:" (case-insensitive, leading whitespace ignored).
// Used for both the Purpose column and the bank message.
// Ports legacy/scripts/match_payments.py is_other_purpose.
func IsOtherPurpose(s string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")
}
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
//
// Search text for member matching: sender + message + user_id.
@@ -30,6 +41,10 @@ type InferredDetails struct {
// Fallback 1: if no members found, retry match on sender alone.
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
//
// A message that starts with "other:" is not a monthly fee: no month is
// inferred (neither from the text nor from the date) and Months holds the
// trimmed message as the single purpose.
//
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
//
@@ -38,12 +53,20 @@ func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear i
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
members := MatchMembers(searchText, memberNames)
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(members) == 0 {
members = MatchMembers(tx.Sender, memberNames)
}
if IsOtherPurpose(tx.Message) {
return InferredDetails{
Members: members,
Months: []string{strings.TrimSpace(tx.Message)},
SearchText: searchText,
}
}
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
if ym := inferMonthFromDate(tx.Date); ym != "" {
months = []string{ym}
+68
View File
@@ -32,6 +32,74 @@ import (
var inferMembers = []string{"Tomáš Němeček (Tov)", "Jana Nováková"}
func TestIsOtherPurpose(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"other: tournament": true,
"other:shirt": true,
" Other: Tournament": true,
"OTHER:x": true,
"leden other: x": false,
"other tournament": false,
"another: x": false,
"2026-01": false,
"": false,
}
for in, want := range cases {
if got := IsOtherPurpose(in); got != want {
t.Errorf("IsOtherPurpose(%q) = %v, want %v", in, got, want)
}
}
}
func TestInferTransactionDetails_OtherMessage(t *testing.T) {
t.Parallel()
jana := []Match{{Name: "Jana Nováková", Confidence: ConfidenceAuto}}
cases := []struct {
name string
tx Transaction
wantMembers []Match
wantMonths []string
}{
{
// Month words and the date must both be ignored.
name: "other prefix: message becomes the purpose, no month",
tx: Transaction{Sender: "Jana Novakova", Message: " other: turnaj leden 2026 ", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"other: turnaj leden 2026"},
},
{
name: "other prefix is case-insensitive and keeps the original text",
tx: Transaction{Sender: "Jana Novakova", Message: "Other: Dres", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"Other: Dres"},
},
{
name: "other prefix with no known member still yields the purpose",
tx: Transaction{Sender: "Unknown Payer", Message: "other: dres", Date: "2026-04-15"},
wantMembers: nil,
wantMonths: []string{"other: dres"},
},
{
name: "other: not at the start is handled the old way",
tx: Transaction{Sender: "Jana Novakova", Message: "leden other: dres", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"2026-01"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := InferTransactionDetails(tc.tx, inferMembers, 2026)
if len(got.Members) != len(tc.wantMembers) || (len(got.Members) > 0 && !reflect.DeepEqual(got.Members, tc.wantMembers)) {
t.Errorf("Members = %v, want %v", got.Members, tc.wantMembers)
}
if !reflect.DeepEqual(got.Months, tc.wantMonths) {
t.Errorf("Months = %v, want %v", got.Months, tc.wantMonths)
}
})
}
}
func TestInferTransactionDetails(t *testing.T) {
t.Parallel()
+6 -1
View File
@@ -203,7 +203,7 @@ func Reconcile(
personStr := strings.TrimSpace(tx.Person)
purposeStr := strings.TrimSpace(tx.Purpose)
personStr = questionMarkRe.ReplaceAllString(personStr, "")
isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:")
isOther := matching.IsOtherPurpose(purposeStr)
var matchedMembers []matching.Match
var matchedMonths []string
@@ -249,6 +249,11 @@ func Reconcile(
matchedMembers = inferred.Members
matchedMonths = inferred.Months
amount = tx.Amount
// A message starting with "other:" is inferred as a non-fee payment.
if len(matchedMonths) == 1 && matching.IsOtherPurpose(matchedMonths[0]) {
isOther = true
purposeStr = matchedMonths[0]
}
}
if len(matchedMembers) == 0 || len(matchedMonths) == 0 {
@@ -257,6 +257,33 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
}
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
// A row not yet processed by inference (empty Person/Purpose) whose message
// starts with "other:" must land in the other ledger, not in a month.
func TestReconcileOtherMessageFallback(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{
Date: "2026-01-10", Amount: 300,
Sender: "Alice", Message: "other: shirt",
}}
result := Reconcile(members, []string{"2026-01"}, txs, nil, 2026)
alice := result.Members["Alice"]
if paid := alice.Months["2026-01"].Paid; paid != 0 {
t.Errorf("2026-01 paid = %v, want 0", paid)
}
if len(alice.OtherTransactions) != 1 {
t.Fatalf("other transactions = %d, want 1", len(alice.OtherTransactions))
}
if got := alice.OtherTransactions[0]; got.Amount != 300 || got.Purpose != "other: shirt" {
t.Errorf("other entry = %+v", got)
}
if len(result.Unmatched) != 0 {
t.Errorf("unmatched = %d, want 0", len(result.Unmatched))
}
}
func TestReconcileOtherPurpose(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
+95
View File
@@ -0,0 +1,95 @@
// Package scheduler runs a job at fixed times of day.
package scheduler
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
)
// TimeOfDay is a wall-clock time without a date.
type TimeOfDay struct {
Hour, Minute int
}
func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) }
// ParseTimes parses a comma-separated list of HH:MM times, e.g. "04:00" or
// "06:00,18:00". "off" yields an empty schedule. The result is sorted and
// de-duplicated.
func ParseTimes(s string) ([]TimeOfDay, error) {
s = strings.TrimSpace(s)
if strings.EqualFold(s, "off") {
return nil, nil
}
seen := map[TimeOfDay]bool{}
var times []TimeOfDay
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
parsed, err := time.Parse("15:04", part)
if err != nil {
return nil, fmt.Errorf("invalid time %q: want HH:MM, a comma-separated list of them, or \"off\"", part)
}
t := TimeOfDay{Hour: parsed.Hour(), Minute: parsed.Minute()}
if !seen[t] {
seen[t] = true
times = append(times, t)
}
}
sort.Slice(times, func(i, j int) bool {
if times[i].Hour != times[j].Hour {
return times[i].Hour < times[j].Hour
}
return times[i].Minute < times[j].Minute
})
return times, nil
}
// Next returns the earliest scheduled instant strictly after now, with the
// times read in loc. It returns the zero time when times is empty.
func Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time {
local := now.In(loc)
var best time.Time
for dayOffset := 0; dayOffset <= 1; dayOffset++ {
for _, t := range times {
c := time.Date(local.Year(), local.Month(), local.Day()+dayOffset, t.Hour, t.Minute, 0, 0, loc)
if c.After(now) && (best.IsZero() || c.Before(best)) {
best = c
}
}
}
return best
}
// pollInterval bounds each wait so the loop follows the wall clock across
// clock adjustments and host suspend instead of relying on one long timer.
const pollInterval = time.Minute
// Run calls job at every scheduled time until ctx is cancelled. Nothing runs
// at startup. Runs do not overlap: the next time is computed after job returns.
func Run(ctx context.Context, times []TimeOfDay, loc *time.Location, logger *slog.Logger, job func(context.Context)) {
for {
next := Next(time.Now(), times, loc)
if next.IsZero() {
return
}
logger.Info("next scheduled run", "at", next.Format(time.RFC3339))
for {
wait := time.Until(next)
if wait <= 0 {
break
}
timer := time.NewTimer(min(wait, pollInterval))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
job(ctx)
}
}
+115
View File
@@ -0,0 +1,115 @@
package scheduler
import (
"context"
"io"
"log/slog"
"reflect"
"testing"
"time"
)
func TestParseTimes(t *testing.T) {
cases := []struct {
in string
want []TimeOfDay
wantErr bool
}{
{"04:00", []TimeOfDay{{4, 0}}, false},
{" 18:30 , 06:00,06:00 ", []TimeOfDay{{6, 0}, {18, 30}}, false},
{"off", nil, false},
{"OFF", nil, false},
{"", nil, true},
{"25:00", nil, true},
{"04:60", nil, true},
{"daily", nil, true},
{"04:00,", nil, true},
}
for _, tc := range cases {
got, err := ParseTimes(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("ParseTimes(%q): err = %v, wantErr %v", tc.in, err, tc.wantErr)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("ParseTimes(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestNext(t *testing.T) {
prague, err := time.LoadLocation("Europe/Prague")
if err != nil {
t.Fatal(err)
}
at := func(s string) time.Time {
v, err := time.ParseInLocation("2006-01-02 15:04:05", s, prague)
if err != nil {
t.Fatal(err)
}
return v
}
four := []TimeOfDay{{4, 0}}
cases := []struct {
name string
now time.Time
times []TimeOfDay
want time.Time
}{
{"before today's time", at("2026-10-08 03:59:59"), four, at("2026-10-08 04:00:00")},
{"exactly at the time moves to tomorrow", at("2026-10-08 04:00:00"), four, at("2026-10-09 04:00:00")},
{"after today's time", at("2026-10-08 12:00:00"), four, at("2026-10-09 04:00:00")},
{"picks the next of several", at("2026-10-08 12:00:00"), []TimeOfDay{{6, 0}, {18, 0}}, at("2026-10-08 18:00:00")},
{"year rollover", at("2026-12-31 23:00:00"), four, at("2027-01-01 04:00:00")},
{"now given in UTC", at("2026-10-08 03:00:00").UTC(), four, at("2026-10-08 04:00:00")},
// Spring forward (2026-03-29 02:00 → 03:00): still 04:00 local, 23h after the previous run.
{"spring forward", at("2026-03-28 04:00:00"), four, at("2026-03-29 04:00:00")},
// Fall back (2026-10-25 03:00 → 02:00): still 04:00 local, 25h after the previous run.
{"fall back", at("2026-10-24 04:00:00"), four, at("2026-10-25 04:00:00")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Next(tc.now, tc.times, prague)
if !got.Equal(tc.want) {
t.Errorf("Next = %s, want %s", got, tc.want)
}
})
}
if got := Next(at("2026-10-08 12:00:00"), nil, prague); !got.IsZero() {
t.Errorf("Next with empty schedule = %s, want zero", got)
}
if d := at("2026-03-29 04:00:00").Sub(at("2026-03-28 04:00:00")); d != 23*time.Hour {
t.Errorf("spring-forward gap = %s, want 23h", d)
}
}
func TestRunStopsOnCancelAndEmptySchedule(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
called := false
job := func(context.Context) { called = true }
done := make(chan struct{})
go func() { Run(context.Background(), nil, time.UTC, logger, job); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run with empty schedule did not return")
}
ctx, cancel := context.WithCancel(context.Background())
done = make(chan struct{})
far := time.Now().UTC().Add(12 * time.Hour)
go func() {
Run(ctx, []TimeOfDay{{far.Hour(), far.Minute()}}, time.UTC, logger, job)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
if called {
t.Error("job must not run before its scheduled time")
}
}
@@ -53,6 +53,42 @@ func TestInferPayments_BasicMatch(t *testing.T) {
}
}
func TestInferPayments_OtherMessage(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:Z": {
paymentsHeader,
// Known member: Person filled, message copied to Purpose, no month.
{"2026-04-10", 300.0, "", "", "", "", "Jana Novakova", "", "other: dres", "", ""},
// Unknown sender: Purpose is still written, Person stays empty.
{"2026-04-11", 500.0, "", "", "", "", "Unknown Payer", "", "Other: turnaj", "", ""},
},
}}
att := &fakeAttendance{
adults: []reconcile.Member{{Name: "Jana Novakova", Tier: "A"}},
}
n, err := InferPayments(context.Background(), "SHEETID", sh, att, InferOpts{})
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("want 2 rows updated, got %d", n)
}
updates := sh.BatchUpdated[0].Updates
want := [][]any{
{"Jana Novakova", "other: dres", "300"},
{"", "Other: turnaj", "500"},
}
for i, w := range want {
got := updates[i].Values[0]
for j := range w {
if got[j] != w[j] {
t.Errorf("row %d col %d: want %q, got %q", i, j, w[j], got[j])
}
}
}
}
func TestInferPayments_SkipRule_ManualFix(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:Z": {
+63
View File
@@ -0,0 +1,63 @@
package banksync
import (
"context"
"errors"
"io"
"sync"
"time"
)
// ErrBusy is returned by Runner.Run when another run is still in progress.
var ErrBusy = errors.New("a sync is already running")
// RunStatus describes one finished run of a Runner.
type RunStatus struct {
Started, Finished time.Time
Trigger string // "manual" or "scheduled"
Err error
}
// Runner serialises bank sync runs so a scheduled run and a manual one never
// overlap (overlapping runs could append the same rows twice), and remembers
// the outcome of the last run for display.
type Runner struct {
job func(ctx context.Context, out io.Writer) error
running sync.Mutex
mu sync.Mutex
last RunStatus
hasLast bool
}
// NewRunner wraps job, which performs one full sync and writes a
// human-readable log to out.
func NewRunner(job func(ctx context.Context, out io.Writer) error) *Runner {
return &Runner{job: job}
}
// Run executes the job unless one is already running, in which case it
// returns ErrBusy without waiting.
func (r *Runner) Run(ctx context.Context, trigger string, out io.Writer) error {
if !r.running.TryLock() {
return ErrBusy
}
defer r.running.Unlock()
st := RunStatus{Started: time.Now(), Trigger: trigger}
st.Err = r.job(ctx, out)
st.Finished = time.Now()
r.mu.Lock()
r.last, r.hasLast = st, true
r.mu.Unlock()
return st.Err
}
// Last returns the outcome of the most recent finished run, if any.
func (r *Runner) Last() (RunStatus, bool) {
r.mu.Lock()
defer r.mu.Unlock()
return r.last, r.hasLast
}
@@ -0,0 +1,65 @@
package banksync
import (
"context"
"errors"
"io"
"testing"
)
func TestRunner_RecordsStatus(t *testing.T) {
r := NewRunner(func(context.Context, io.Writer) error { return nil })
if _, ok := r.Last(); ok {
t.Fatal("Last before any run: want ok=false")
}
if err := r.Run(context.Background(), "scheduled", io.Discard); err != nil {
t.Fatal(err)
}
st, ok := r.Last()
if !ok || st.Trigger != "scheduled" || st.Err != nil || st.Finished.Before(st.Started) {
t.Errorf("unexpected status %+v ok=%v", st, ok)
}
}
func TestRunner_RecordsError(t *testing.T) {
boom := errors.New("boom")
r := NewRunner(func(context.Context, io.Writer) error { return boom })
if err := r.Run(context.Background(), "manual", io.Discard); !errors.Is(err, boom) {
t.Fatalf("Run err = %v, want boom", err)
}
if st, _ := r.Last(); !errors.Is(st.Err, boom) || st.Trigger != "manual" {
t.Errorf("unexpected status %+v", st)
}
}
func TestRunner_BusyWhileRunning(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
first := true
r := NewRunner(func(context.Context, io.Writer) error {
if first { // runs are serialised by Runner, so no extra locking is needed
first = false
close(started)
<-release
}
return nil
})
done := make(chan error, 1)
go func() { done <- r.Run(context.Background(), "scheduled", io.Discard) }()
<-started
if err := r.Run(context.Background(), "manual", io.Discard); !errors.Is(err, ErrBusy) {
t.Errorf("second Run err = %v, want ErrBusy", err)
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
// The skipped run must not overwrite the status of the real one.
if st, _ := r.Last(); st.Trigger != "scheduled" {
t.Errorf("Last trigger = %q, want scheduled", st.Trigger)
}
if err := r.Run(context.Background(), "manual", io.Discard); err != nil {
t.Errorf("Run after release: %v", err)
}
}
+6 -1
View File
@@ -242,7 +242,12 @@ func buildJuniorMemberRow(
tooltip := ""
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
// Unknown expected (one session, manual review) shows as "?", as in Python.
expectedText := strconv.Itoa(md.Expected)
if md.IsUnknown {
expectedText = "?"
}
tooltip = fmt.Sprintf("Received: %d, Expected: %s", paid, expectedText)
}
row.Months = append(row.Months, MonthCell{
+19 -10
View File
@@ -21,17 +21,26 @@ func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHan
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
}
// page builds the data shared by every page (nav highlight, build info, sync summary).
func (h *HTMLHandler) page(active string) PageData {
pd := PageData{Active: active, Build: h.build}
if h.actions.SyncInfo != nil {
pd.SyncInfo = h.actions.SyncInfo()
}
return pd
}
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssembleAdults(r.Context())
if err != nil {
h.renderer.Render(w, "adults", AdultsPageData{
PageData: PageData{Active: "adults", Build: h.build},
PageData: h.page("adults"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "adults", AdultsPageData{
PageData: PageData{Active: "adults", Build: h.build},
PageData: h.page("adults"),
Data: data,
})
}
@@ -40,13 +49,13 @@ func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssembleJuniors(r.Context())
if err != nil {
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
PageData: h.page("juniors"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
PageData: h.page("juniors"),
Data: data,
})
}
@@ -55,20 +64,20 @@ func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssemblePayments(r.Context())
if err != nil {
h.renderer.Render(w, "payments", PaymentsPageData{
PageData: PageData{Active: "payments", Build: h.build},
PageData: h.page("payments"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "payments", PaymentsPageData{
PageData: PageData{Active: "payments", Build: h.build},
PageData: h.page("payments"),
Data: data,
})
}
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
pd := PageData{Active: "sync", Build: h.build}
pd := h.page("sync")
if h.actions.BankSync == nil {
h.renderer.Render(w, "sync", SyncPageData{
@@ -96,7 +105,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
}
h.renderer.Render(w, "sync", SyncPageData{
PageData: pd,
PageData: h.page("sync"), // rebuilt so the footer reflects this run
Output: buf.String(),
Success: success,
})
@@ -105,7 +114,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: PageData{Active: "flush", Build: h.build},
PageData: h.page("flush"),
})
}
@@ -113,7 +122,7 @@ func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request)
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
n, _ := h.apiHandler.FlushCache()
h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: PageData{Active: "flush", Build: h.build},
PageData: h.page("flush"),
Flushed: true,
Deleted: n,
})
+22
View File
@@ -314,6 +314,28 @@ func TestServeSync(t *testing.T) {
}
}
func TestFooterSyncInfo(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
get := func(actions web.ActionHandlers) string {
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
w := httptest.NewRecorder()
h.ServeFlushCacheGET(w, httptest.NewRequest(http.MethodGet, "/flush-cache", nil))
return w.Body.String()
}
const info = "last sync 2026-10-08 04:00 (scheduled) ok"
if body := get(web.ActionHandlers{SyncInfo: func() string { return info }}); !strings.Contains(body, "| "+info) {
t.Errorf("footer missing sync info %q", info)
}
if body := get(web.ActionHandlers{}); strings.Contains(body, "last sync") {
t.Errorf("footer shows sync info although none was provided")
}
}
func TestPaymentQRMarkup(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
+1
View File
@@ -12,6 +12,7 @@ import (
type PageData struct {
Active string
Build BuildInfo
SyncInfo string // last/next bank sync summary shown in the footer; empty hides it
}
// AdultsPageData is the view model for the /adults HTML page.
+3
View File
@@ -26,6 +26,9 @@ 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
// SyncInfo returns a one-line summary of the last and next bank sync for
// the page footer. nil or an empty result shows nothing.
SyncInfo func() string
}
// Run registers routes and starts the HTTP server on addr.
@@ -1,3 +1,3 @@
{{define "footer"}}
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}{{if .SyncInfo}} | {{.SyncInfo}}{{end}}</div>
{{end}}
+1 -1
View File
@@ -9,7 +9,7 @@
{{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>
<p class="description">Fetches Fio transactions for the current year and the last two months of the previous one, infers payment details, and flushes the cache.</p>
{{end}}
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
{{end}}
@@ -0,0 +1,13 @@
{
"case": "one_session_free_august",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 1,
"month_key": "2026-08"
},
"output": {
"value": 0,
"unknown": false
}
}
@@ -0,0 +1,13 @@
{
"case": "two_sessions_free_august",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 2,
"month_key": "2026-08"
},
"output": {
"value": 0,
"unknown": false
}
}
@@ -0,0 +1,36 @@
{
"case": "other_not_at_start",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Jana Novakova",
"message": "leden other: dres",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [
{
"name": "Jana Nováková",
"confidence": "auto"
}
],
"months": [
"2026-01"
],
"search_text": "Jana Novakova leden other: dres "
}
}
@@ -0,0 +1,36 @@
{
"case": "other_prefix",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Jana Novakova",
"message": "other: tournament leden 2026",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [
{
"name": "Jana Nováková",
"confidence": "auto"
}
],
"months": [
"other: tournament leden 2026"
],
"search_text": "Jana Novakova other: tournament leden 2026 "
}
}
@@ -0,0 +1,31 @@
{
"case": "other_prefix_uppercase",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Test Payer",
"message": " Other: Dres ",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [],
"months": [
"Other: Dres"
],
"search_text": "Test Payer Other: Dres "
}
}
@@ -0,0 +1,69 @@
{
"case": "11_other_message_fallback",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-10-08",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
]
}
}
],
"sorted_months": [
"2026-01"
],
"transactions": [
{
"date": "2026-01-10",
"amount": 300,
"manual_fix": "",
"person": "",
"purpose": "",
"inferred_amount": 300,
"sender": "Member_d035d9f9",
"message": "other: tournament",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 0.0,
"transactions": []
}
},
"other_transactions": [
{
"amount": 300.0,
"date": "2026-01-10",
"sender": "Member_d035d9f9",
"message": "other: tournament",
"purpose": "other: tournament",
"confidence": "auto"
}
],
"total_balance": -750
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": -750
}
}
}
+2
View File
@@ -121,6 +121,8 @@ def flush_cache_endpoint():
deleted = flush_cache()
return render_template("flush-cache.html", flushed=True, deleted=deleted)
# Scheduled (daily) bank sync exists only in the Go server, on purpose: this
# app is legacy and being phased out. Here the sync runs only on request.
@app.route("/sync-bank")
def sync_bank():
import contextlib
+44
View File
@@ -146,6 +146,12 @@ SEEDS[("calculate_junior_fee", "two_sessions_reduced_sep")] = {
SEEDS[("calculate_junior_fee", "two_sessions_default_fallback")] = {
"attendance_count": 2, "month_key": "2099-06"
}
SEEDS[("calculate_junior_fee", "two_sessions_free_august")] = {
"attendance_count": 2, "month_key": "2026-08"
}
SEEDS[("calculate_junior_fee", "one_session_free_august")] = {
"attendance_count": 1, "month_key": "2026-08"
}
# --- parse_czk_amount ---
@@ -358,6 +364,36 @@ SEEDS[("infer_transaction_details", "serial_date")] = {
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_prefix")] = {
"tx": {
"sender": "Jana Novakova",
"message": "other: tournament leden 2026",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_prefix_uppercase")] = {
"tx": {
"sender": "Test Payer",
"message": " Other: Dres ",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_not_at_start")] = {
"tx": {
"sender": "Jana Novakova",
"message": "leden other: dres",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "no_member_no_month")] = {
"tx": {
"sender": "Unknown Person",
@@ -476,6 +512,14 @@ SEEDS[("reconcile", "07_other_purpose_split")] = _reconcile_seed(
transactions=[_tx("2026-01-10", 800, "Alice Dvořák, Bob Kratochvíl", "other:tournament", sender="Alice Dvořák")],
)
# 11 — message starts with "other:" on a row not yet inferred (empty Person/Purpose):
# goes to the other ledger, the month stays unpaid
SEEDS[("reconcile", "11_other_message_fallback")] = _reconcile_seed(
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
sorted_months=["2026-01"],
transactions=[_tx("2026-01-10", 300, "", "", sender="Alice Dvořák", message="other: tournament")],
)
# 08 — junior with attendance=1 (expected=0 in reconcile, unknown in UI)
SEEDS[("reconcile", "08_junior_question_mark")] = _reconcile_seed(
members=[_member("Karel Junior", "A", {"2026-01": (0, 1)})],
+6 -4
View File
@@ -34,7 +34,8 @@ ADULT_FEE_MONTHLY_RATE = {
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
JUNIOR_MONTHLY_RATE = {
"2025-09": 250,
"2026-03": 250 # reduced fee for March 2026
"2026-03": 250, # reduced fee for March 2026
"2026-08": 0,
}
ADULT_MERGED_MONTHS = {
"2025-09": "2025-10", # keys are merged into values
@@ -105,12 +106,13 @@ def calculate_fee(attendance_count: int, month_key: str) -> int:
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
"""Apply junior fee rules: 0 → 0, 1 → '?', 2+ → Configured Rate (default 500)."""
"""Apply junior fee rules: 0 → 0, 1 → '?' (0 in a 0-rate month), 2+ → Configured Rate (default 500)."""
if attendance_count == 0:
return 0
if attendance_count == 1:
rate = JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
if attendance_count == 1 and rate > 0:
return "?"
return JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
return rate
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
+30 -5
View File
@@ -150,27 +150,48 @@ def match_members(
# Reconciliation
# ---------------------------------------------------------------------------
def is_other_purpose(text) -> bool:
"""True when text marks a payment that is not a monthly fee.
It starts with "other:" (case-insensitive, leading whitespace ignored).
Used for both the Purpose column and the bank message.
"""
return str(text or "").strip().lower().startswith("other:")
def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
"""Infer member(s) and month(s) for a single transaction.
A message that starts with "other:" is not a monthly fee: no month is
inferred (neither from the text nor from the date) and 'months' holds the
trimmed message as the single purpose.
Returns:
{
'members': [(name, confidence)],
'months': [YYYY-MM],
'months': [YYYY-MM] or ['other:…'],
'matched_text': str
}
"""
# Combine sender + message for searching
search_text = f"{tx.get('sender', '')} {tx.get('message', '')} {tx.get('user_id', '')}"
matched_members = match_members(search_text, member_names)
matched_months = parse_month_references(
tx.get("message", "") + " " + tx.get("user_id", "")
)
if not matched_members:
# Try matching sender name alone with more lenient matching
matched_members = match_members(tx.get("sender", ""), member_names)
if is_other_purpose(tx.get("message", "")):
return {
"members": matched_members,
"months": [str(tx.get("message", "")).strip()],
"search_text": search_text
}
matched_months = parse_month_references(
tx.get("message", "") + " " + tx.get("user_id", "")
)
if not matched_months:
# If no month specified, try to infer from payment date
tx_date = tx.get("date")
@@ -394,7 +415,7 @@ def reconcile(
# Strip markers like [?]
person_str = re.sub(r"\[\?\]\s*", "", person_str)
is_other = purpose_str.lower().startswith("other:")
is_other = is_other_purpose(purpose_str)
if person_str and purpose_str:
# We have pre-matched data (either from script or manual)
@@ -415,6 +436,10 @@ def reconcile(
inference = infer_transaction_details(tx, member_names)
matched_members = inference["members"]
matched_months = inference["months"]
# A message starting with "other:" is inferred as a non-fee payment.
if len(matched_months) == 1 and is_other_purpose(matched_months[0]):
is_other = True
purpose_str = matched_months[0]
amount = tx.get("amount", 0)
try:
amount = float(amount)
+44 -1
View File
@@ -1,7 +1,9 @@
import unittest
from unittest.mock import MagicMock, patch
from scripts.match_payments import canonical_member_key, fetch_sheet_data, reconcile
from scripts.match_payments import (
canonical_member_key, fetch_sheet_data, infer_transaction_details, is_other_purpose, reconcile,
)
class TestCanonicalMemberKey(unittest.TestCase):
@@ -88,3 +90,44 @@ class TestFetchSheetDataVS(unittest.TestCase):
def test_string_vs_is_kept(self):
self.assertEqual(self._fetch("0308")[0]["vs"], "0308")
class TestOtherPurposeInference(unittest.TestCase):
"""A message starting with "other:" is not a monthly fee."""
MEMBERS = ["Jana Nováková", "Adam Novák"]
def test_is_other_purpose(self):
for text in ("other: tournament", "other:shirt", " Other: Tournament", "OTHER:x"):
self.assertTrue(is_other_purpose(text), text)
for text in ("leden other: x", "other tournament", "another: x", "2026-01", "", None):
self.assertFalse(is_other_purpose(text), text)
def test_message_becomes_purpose_and_no_month_is_inferred(self):
tx = {"sender": "Jana Novakova", "message": " other: turnaj leden 2026 ", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["members"], [("Jana Nováková", "auto")])
self.assertEqual(result["months"], ["other: turnaj leden 2026"])
def test_unknown_member_still_yields_purpose(self):
tx = {"sender": "Unknown Payer", "message": "Other: Dres", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["members"], [])
self.assertEqual(result["months"], ["Other: Dres"])
def test_other_not_at_start_is_handled_the_old_way(self):
tx = {"sender": "Jana Novakova", "message": "leden other: dres", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["months"], ["2026-01"])
def test_reconcile_fallback_puts_it_in_other_ledger(self):
members = [("Jana Nováková", "A", {"2026-01": (750, 3)})]
tx = {"date": "2026-01-10", "amount": 300, "person": "", "purpose": "",
"sender": "Jana Novakova", "message": "other: dres", "inferred_amount": ""}
result = reconcile(members, ["2026-01"], [tx], {})
member = result["members"]["Jana Nováková"]
self.assertEqual(member["months"]["2026-01"]["paid"], 0)
self.assertEqual(len(member["other_transactions"]), 1)
self.assertEqual(member["other_transactions"][0]["amount"], 300)
self.assertEqual(member["other_transactions"][0]["purpose"], "other: dres")
self.assertEqual(result["unmatched"], [])