Files
carvalho-finances/apps/api/internal/parser/csv.go
T
Mlcavalho1andClaude Sonnet 4.6 e438002c0a feat: importação de fatura de cartão — strip R$, skip negativos, all_expenses
Parser limpa prefixo R$ e símbolos de moeda antes de parsear o valor.
Flags SkipNegative e AllExpenses para CSV de fatura de cartão.
UI: checkboxes "Fatura de cartão" e "Ignorar valores negativos".

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 15:56:14 -03:00

118 lines
2.4 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])
// Skip blank rows (e.g. empty line after header in some bank exports)
if dateStr == "" && amtStr == "" && desc == "" {
continue
}
t, err := time.Parse(m.DateFormat, dateStr)
if err != nil {
errs = append(errs, fmt.Sprintf("linha %d: data inválida %q", lineNum, dateStr))
continue
}
// Strip currency symbols (R$, $, €, etc.) keeping digits, comma, dot, minus
cleaned := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' || r == ',' || r == '.' || r == '-' {
return r
}
return -1
}, amtStr)
cleaned = strings.TrimSpace(cleaned)
if m.DecimalSeparator == "," {
cleaned = strings.ReplaceAll(cleaned, ".", "")
cleaned = strings.ReplaceAll(cleaned, ",", ".")
}
amt, err := strconv.ParseFloat(cleaned, 64)
if err != nil || cleaned == "" {
errs = append(errs, fmt.Sprintf("linha %d: valor inválido %q", lineNum, amtStr))
continue
}
if m.SkipNegative && amt < 0 {
continue
}
txType := "expense"
if !m.AllExpenses && 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
}