Files
carvalho-finances/apps/api/internal/parser/csv.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

100 lines
1.9 KiB
Go

package parser
import (
"encoding/csv"
"fmt"
"io"
"math"
"strconv"
"strings"
"time"
"financeiro-carvalho/internal/model"
)
func ParseCSV(r io.Reader, m model.CSVMapping) ([]model.ImportRow, []string) {
if m.DateFormat == "" {
m.DateFormat = "02/01/2006"
}
if m.DecimalSeparator == "" {
m.DecimalSeparator = ","
}
fieldSep := ','
if m.FieldSeparator == ";" {
fieldSep = ';'
}
reader := csv.NewReader(r)
reader.Comma = fieldSep
reader.LazyQuotes = true
reader.TrimLeadingSpace = true
var rows []model.ImportRow
var errs []string
lineNum := 0
for {
record, err := reader.Read()
if err == io.EOF {
break
}
lineNum++
if err != nil {
errs = append(errs, fmt.Sprintf("linha %d: %v", lineNum, err))
continue
}
if lineNum == 1 && m.HasHeader {
continue
}
maxCol := max3(m.DateColumn, m.AmountColumn, m.DescriptionColumn)
if len(record) <= maxCol {
errs = append(errs, fmt.Sprintf("linha %d: colunas insuficientes (%d)", lineNum, len(record)))
continue
}
dateStr := strings.TrimSpace(record[m.DateColumn])
amtStr := strings.TrimSpace(record[m.AmountColumn])
desc := cleanDescription(record[m.DescriptionColumn])
t, err := time.Parse(m.DateFormat, dateStr)
if err != nil {
errs = append(errs, fmt.Sprintf("linha %d: data inválida %q", lineNum, dateStr))
continue
}
if m.DecimalSeparator == "," {
amtStr = strings.ReplaceAll(amtStr, ".", "")
amtStr = strings.ReplaceAll(amtStr, ",", ".")
}
amt, err := strconv.ParseFloat(amtStr, 64)
if err != nil {
errs = append(errs, fmt.Sprintf("linha %d: valor inválido %q", lineNum, amtStr))
continue
}
txType := "expense"
if amt > 0 {
txType = "income"
}
rows = append(rows, model.ImportRow{
Date: t.Format("2006-01-02"),
Amount: math.Abs(amt),
Description: desc,
Type: txType,
})
}
return rows, errs
}
func max3(a, b, c int) int {
if b > a {
a = b
}
if c > a {
a = c
}
return a
}