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>
363 lines
10 KiB
Go
363 lines
10 KiB
Go
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=..."
|
|
var (
|
|
version = "dev"
|
|
commit = "unknown"
|
|
buildDate = "unknown"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
|
|
// Honour LOG_LEVEL for slog calls in any package (e.g. internal/io/fio debug logs).
|
|
slog.SetDefault(logging.New(os.Getenv("LOG_LEVEL")))
|
|
|
|
cmd, args := os.Args[1], os.Args[2:]
|
|
|
|
switch cmd {
|
|
case "server":
|
|
serverCmd(args)
|
|
case "version":
|
|
versionCmd()
|
|
case "fees":
|
|
feesCmd(args)
|
|
case "reconcile":
|
|
reconcileCmd(args)
|
|
case "sync":
|
|
syncCmd(args)
|
|
case "infer":
|
|
inferCmd(args)
|
|
case "-h", "--help", "help":
|
|
usage()
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "fuj: unknown command %q\n\n", cmd)
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func serverCmd(args []string) {
|
|
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
|
addr := fs.String("addr", "", "listen address (default from SERVER_ADDR env or :8080)")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, "usage: fuj server [--addr :8080]")
|
|
fs.PrintDefaults()
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
cfg := config.Load()
|
|
if *addr != "" {
|
|
cfg.ServerAddr = *addr
|
|
}
|
|
|
|
ctx := context.Background()
|
|
logger := logging.New(cfg.LogLevel)
|
|
|
|
sources, err := membership.NewSources(ctx, cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj server: init sources: %v\n", err)
|
|
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)
|
|
|
|
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 ===")
|
|
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
|
|
},
|
|
)
|
|
|
|
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}
|
|
|
|
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
fmt.Fprintln(os.Stderr, "usage: fuj fees")
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
cfg := config.Load()
|
|
sources, err := membership.NewSources(ctx, cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj fees: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := membership.FeesReport(ctx, sources, os.Stdout); err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj fees: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func reconcileCmd(args []string) {
|
|
fs := flag.NewFlagSet("reconcile", flag.ExitOnError)
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, "usage: fuj reconcile")
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
cfg := config.Load()
|
|
sources, err := membership.NewSources(ctx, cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj reconcile: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := membership.ReconcileReport(ctx, sources, time.Now().Year(), os.Stdout); err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj reconcile: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func versionCmd() {
|
|
fmt.Printf("fuj %s (%s) built %s\n", version, commit, buildDate)
|
|
}
|
|
|
|
func syncCmd(args []string) {
|
|
fs := flag.NewFlagSet("sync", flag.ExitOnError)
|
|
days := fs.Int("days", 30, "look-back window in days (ignored when --from/--to are set)")
|
|
fromStr := fs.String("from", "", "start date YYYY-MM-DD")
|
|
toStr := fs.String("to", "", "end date YYYY-MM-DD")
|
|
sort := fs.Bool("sort", true, "sort sheet by date after appending")
|
|
dryRun := fs.Bool("dry-run", false, "print planned writes without modifying the sheet")
|
|
printFioTable := fs.Bool("print-fio-table", false, "with --dry-run: print aligned table of every Fio transaction with NEW/DUP status")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort] [--dry-run] [--print-fio-table]")
|
|
fs.PrintDefaults()
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
cfg := config.Load()
|
|
|
|
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj sync: sheets client: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fioClients := buildFioClients(cfg)
|
|
|
|
opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable}
|
|
if *fromStr != "" && *toStr != "" {
|
|
opts.From, err = time.Parse("2006-01-02", *fromStr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj sync: invalid --from: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
opts.To, err = time.Parse("2006-01-02", *toStr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj sync: invalid --to: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli, opts)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if *dryRun {
|
|
fmt.Printf("Dry run: would sync %d new transaction(s).\n", n)
|
|
} else {
|
|
fmt.Printf("Synced %d new transaction(s).\n", n)
|
|
}
|
|
}
|
|
|
|
func inferCmd(args []string) {
|
|
fs := flag.NewFlagSet("infer", flag.ExitOnError)
|
|
dryRun := fs.Bool("dry-run", false, "print planned updates without writing to the sheet")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, "usage: fuj infer [--dry-run]")
|
|
fs.PrintDefaults()
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
cfg := config.Load()
|
|
|
|
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj infer: sheets client: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
sources, err := membership.NewSources(ctx, cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj infer: sources: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
n, err := banksync.InferPayments(ctx, config.PaymentsSheetID, sheetsCli, sources, banksync.InferOpts{DryRun: *dryRun})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fuj infer: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if *dryRun {
|
|
fmt.Printf("Dry run: would update %d row(s).\n", n)
|
|
} else {
|
|
fmt.Printf("Updated %d row(s).\n", n)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintln(os.Stderr, `usage: fuj <command> [flags]
|
|
|
|
Commands:
|
|
server Start HTTP server (default :8080)
|
|
version Print version information
|
|
fees Calculate monthly fees
|
|
reconcile Show balance report
|
|
sync Sync Fio transactions to payments sheet
|
|
infer Infer payment details in payments sheet`)
|
|
}
|
|
|
|
// buildFioClients constructs one fio.Client per configured account.
|
|
// Each client uses the account's token if available, otherwise the transparent-scraper path.
|
|
func buildFioClients(cfg config.Config) []fio.Client {
|
|
clients := make([]fio.Client, 0, len(cfg.LoadedAccounts))
|
|
for _, a := range cfg.LoadedAccounts {
|
|
clients = append(clients, fio.New(a.Token, a.AcctNum, nil))
|
|
}
|
|
return clients
|
|
}
|