# 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).