package service import ( "context" "fmt" "io" "path/filepath" "strings" "financeiro-carvalho/internal/model" "financeiro-carvalho/internal/parser" "financeiro-carvalho/internal/repository" ) func normalizeDesc(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) } type ImportService struct { repo repository.TransactionRepository } func NewImportService(repo repository.TransactionRepository) *ImportService { return &ImportService{repo: repo} } // Preview parses the file, marks duplicates, and pre-fills category_id from history. func (s *ImportService) Preview(ctx context.Context, filename string, r io.Reader, csvMapping *model.CSVMapping) ([]model.ImportRow, []string, error) { rows, parseErrs, err := s.parse(filename, r, csvMapping) if err != nil { return nil, nil, err } // Collect unique descriptions for history lookup descs := make([]string, 0, len(rows)) seen := make(map[string]bool) for _, row := range rows { n := normalizeDesc(row.Description) if !seen[n] { descs = append(descs, n) seen[n] = true } } catByDesc, _ := s.repo.CategoryByDescription(ctx, descs) for i := range rows { dup, err := s.isDuplicate(ctx, &rows[i]) if err != nil { return nil, nil, fmt.Errorf("dedup check: %w", err) } rows[i].IsDuplicate = dup // Pre-fill category from history for non-duplicate rows that have no category yet if !dup && rows[i].CategoryID == nil { if catID, ok := catByDesc[normalizeDesc(rows[i].Description)]; ok { rows[i].CategoryID = &catID } } } return rows, parseErrs, nil } // Confirm saves the non-duplicate rows and logs the operation. func (s *ImportService) Confirm(ctx context.Context, filename string, rows []model.ImportRow, parseErrCount int) (model.ImportResult, error) { imported, err := s.repo.BulkInsert(ctx, rows) if err != nil { return model.ImportResult{}, err } duplicates := 0 for _, r := range rows { if r.IsDuplicate { duplicates++ } } format := strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".") if format != "ofx" && format != "csv" { format = "csv" } _ = s.repo.SaveImportLog(ctx, filename, format, imported, duplicates, parseErrCount) return model.ImportResult{ Imported: imported, Duplicates: duplicates, Errors: parseErrCount, }, nil } func (s *ImportService) parse(filename string, r io.Reader, csvMapping *model.CSVMapping) ([]model.ImportRow, []string, error) { ext := strings.ToLower(filepath.Ext(filename)) switch ext { case ".ofx": rows, err := parser.ParseOFX(r) return rows, nil, err case ".csv": m := model.CSVMapping{ DateColumn: 0, AmountColumn: 1, DescriptionColumn: 2, DateFormat: "02/01/2006", HasHeader: true, DecimalSeparator: ",", FieldSeparator: ";", } if csvMapping != nil { m = *csvMapping } rows, errs := parser.ParseCSV(r, m) return rows, errs, nil default: return nil, nil, fmt.Errorf("unsupported file format: %q (use .ofx or .csv)", ext) } } func (s *ImportService) isDuplicate(ctx context.Context, row *model.ImportRow) (bool, error) { if row.ExternalID != "" { return s.repo.IsExternalIDKnown(ctx, row.ExternalID) } return s.repo.IsDuplicate(ctx, row.Date, row.Description, row.Amount) }