Files
fuj-management/go/cmd/fuj/main.go
Jan Novak fe935235e8
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages
- GET /qr: Czech QR Platba PNG; ports Python qr_code() exactly
  (account validation, amount clamping, * stripping, SPD format)
- GET /sync-bank: Fio sync → infer → cache flush with captured log
- GET+POST /flush-cache: form + action, shows deleted count
- GET /version: JSON alias of /api/version (Python parity)
- FlushCache() added to membership.Sources; wired through api.Handler
- web.ActionHandlers{BankSync} closure-based dep injection for sync
- New dep: github.com/skip2/go-qrcode
- TestQRBuildSPD (9 cases), TestServeQR, TestServeFlushCache{GET,POST},
  TestServeSync, TestServeVersion added

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:26:54 +02:00

277 lines
7.5 KiB
Go

package main
import (
"context"
"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/services/banksync"
"fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web"
"io"
"log/slog"
"os"
"time"
)
// 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)
}
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error {
yr := time.Now().Year()
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli,
banksync.SyncOpts{From: from, To: to, Sort: true})
if err != nil {
return fmt.Errorf("sync: %w", err)
}
fmt.Fprintf(out, "Synced %d new transaction(s).\n\n", n)
fmt.Fprintln(out, "=== Infer Payments ===")
n, err = banksync.InferPayments(ctx, config.PaymentsSheetID, sheetsCli, sources, banksync.InferOpts{})
if err != nil {
return fmt.Errorf("infer: %w", err)
}
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
return nil
},
}
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
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)
}
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
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, fioCli, 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`)
}