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) } fioClients := buildFioClients(cfg) 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, 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 }, } 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) } 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 [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 }