// Package banksync implements the bank-sync and payment-inference operations. package banksync import ( "context" "fmt" "fuj-management/go/internal/domain/synch" "fuj-management/go/internal/io/fio" "os" "strings" "time" ) // columnLabels is the canonical header for the payments sheet. // Mirrors COLUMN_LABELS in scripts/sync_fio_to_sheets.py. var columnLabels = []string{ "Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID", } // sheetsWriter is the subset of *sheets.Client used by SyncToSheets. type sheetsWriter interface { GetValues(ctx context.Context, spreadsheetID, a1Range string) ([][]any, error) AppendValues(ctx context.Context, spreadsheetID, a1Range string, rows [][]any) error WriteHeader(ctx context.Context, spreadsheetID string, labels []string) error SortByDateColumn(ctx context.Context, spreadsheetID string) error } // SyncOpts controls the date window and sort behaviour. type SyncOpts struct { Days int // look-back window when From/To are zero From, To time.Time // explicit window (overrides Days) Sort bool // sort the sheet by Date after appending DryRun bool // print planned writes without modifying the sheet PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status } // SyncToSheets fetches Fio transactions and appends new ones to the payments sheet. // Returns the number of rows appended. // Ports scripts/sync_fio_to_sheets.py sync_to_sheets. func SyncToSheets( ctx context.Context, spreadsheetID string, fioClient fio.Client, sh sheetsWriter, opts SyncOpts, ) (int, error) { // 1. Read existing rows to collect known Sync IDs (column K, index 10). rows, err := sh.GetValues(ctx, spreadsheetID, "A1:K") if err != nil { return 0, fmt.Errorf("sync: read sheet: %w", err) } existingIDs := make(map[string]bool) if len(rows) > 0 { header := rows[0] if !headerMatches(header) { if opts.DryRun { fmt.Println("Dry run: would write header row") } else { if err := sh.WriteHeader(ctx, spreadsheetID, columnLabels); err != nil { return 0, fmt.Errorf("sync: write header: %w", err) } } } else { for _, row := range rows[1:] { if len(row) > 10 { if id, ok := row[10].(string); ok && id != "" { existingIDs[id] = true } } } } } // 2. Compute date window. from, to := opts.From, opts.To if from.IsZero() || to.IsZero() { to = time.Now() days := opts.Days if days <= 0 { days = 30 } from = to.AddDate(0, 0, -days) } // 3. Fetch Fio transactions. txns, err := fioClient.FetchTransactions(ctx, from, to) if err != nil { return 0, fmt.Errorf("sync: fetch fio: %w", err) } if opts.DryRun { fmt.Printf("Dry run: window %s to %s, fetched %d transaction(s) from Fio\n", from.Format("2006-01-02"), to.Format("2006-01-02"), len(txns)) } // 4a. Compute Sync IDs for every fetched txn (shared by table-print and row-build). syncIDs := make([]string, len(txns)) for i, tx := range txns { currency := tx.Currency if currency == "" { currency = "CZK" } syncIDs[i] = synch.GenerateSyncID(synch.Transaction{ Date: tx.Date, Amount: tx.Amount, Currency: currency, Sender: tx.Sender, VS: tx.VS, Message: tx.Message, BankID: tx.BankID, }) } // 4b. Optional debug table (dry-run only; suppress when nothing was fetched). if opts.DryRun && opts.PrintFioTable && len(txns) > 0 { printFioTable(os.Stdout, txns, syncIDs, existingIDs) } // 4c. Build new rows. var newRows [][]any for i, tx := range txns { if existingIDs[syncIDs[i]] { continue } newRows = append(newRows, []any{ tx.Date, tx.Amount, "", "", "", "", // manual fix, Person, Purpose, Inferred Amount tx.Sender, tx.VS, tx.Message, tx.BankID, syncIDs[i], }) } if len(newRows) == 0 { return 0, nil } if opts.DryRun { for _, row := range newRows { fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n", row[0], row[1], row[6], row[7], row[8]) } if opts.Sort { fmt.Println("Dry run: would sort by date") } return len(newRows), nil } if err := sh.AppendValues(ctx, spreadsheetID, "A2", newRows); err != nil { return 0, fmt.Errorf("sync: append: %w", err) } if opts.Sort { if err := sh.SortByDateColumn(ctx, spreadsheetID); err != nil { return 0, fmt.Errorf("sync: sort: %w", err) } } return len(newRows), nil } func headerMatches(row []any) bool { if len(row) < len(columnLabels) { return false } for i, label := range columnLabels { cell, _ := row[i].(string) if !strings.EqualFold(cell, label) { return false } } return true }