Files
carvalho-finances/apps/api/internal/service/import.go
T
Mlcavalho1andClaude Sonnet 4.6 83dff2de78 feat(#18): import de extratos OFX e CSV — parser, dedup, preview e confirmação
Parser OFX tolerante ao formato SGML de bancos brasileiros (sem closing tags, BOM,
timezone suffix). Parser CSV com separador de campo/decimal configurável. Dedup por
FITID (OFX) ou date+amount+desc normalizado. Fluxo: POST /preview → revisão na UI
→ POST /confirm salva e loga. Migration 002 adiciona external_id com unique index.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 20:17:46 -03:00

99 lines
2.6 KiB
Go

package service
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/parser"
"financeiro-carvalho/internal/repository"
)
type ImportService struct {
repo repository.TransactionRepository
}
func NewImportService(repo repository.TransactionRepository) *ImportService {
return &ImportService{repo: repo}
}
// Preview parses the file and marks duplicates without saving anything.
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
}
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
}
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)
}