Files
fuj-management/docs/plans/2026-10-08-1216-scheduled-bank-sync.md
T
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

6.6 KiB

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