All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
- 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>
277 lines
7.5 KiB
Go
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`)
|
|
}
|