// 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" "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 } // 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 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) } // 4. Append new rows. var newRows [][]any for _, tx := range txns { currency := tx.Currency if currency == "" { currency = "CZK" } id := synch.GenerateSyncID(synch.Transaction{ Date: tx.Date, Amount: tx.Amount, Currency: currency, Sender: tx.Sender, VS: tx.VS, Message: tx.Message, BankID: tx.BankID, }) if existingIDs[id] { continue } newRows = append(newRows, []any{ tx.Date, tx.Amount, "", "", "", "", // manual fix, Person, Purpose, Inferred Amount tx.Sender, tx.VS, tx.Message, tx.BankID, id, }) } if len(newRows) == 0 { return 0, 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 }