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]>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user