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:
2026-05-26 20:17:46 -03:00
co-authored by Claude Sonnet 4.6
parent d6782d51a5
commit 83dff2de78
15 changed files with 1065 additions and 3 deletions
+99
View File
@@ -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
}
+114
View File
@@ -0,0 +1,114 @@
package parser
import (
"bufio"
"io"
"math"
"strconv"
"strings"
"time"
"financeiro-carvalho/internal/model"
)
// ParseOFX parses OFX SGML files as exported by Brazilian banks.
// It is tolerant of the non-strict SGML format (no closing tags, BOM, etc.).
func ParseOFX(r io.Reader) ([]model.ImportRow, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
var rows []model.ImportRow
var cur *model.ImportRow
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
line = strings.TrimPrefix(line, "\xef\xbb\xbf") // strip UTF-8 BOM if present
if line == "" {
continue
}
tag, value := splitOFXLine(line)
switch tag {
case "STMTTRN":
if cur != nil {
rows = append(rows, *cur)
}
cur = &model.ImportRow{}
case "/STMTTRN":
if cur != nil {
rows = append(rows, *cur)
cur = nil
}
case "DTPOSTED":
if cur != nil {
cur.Date = parseOFXDate(value)
}
case "TRNAMT":
if cur != nil {
amt, _ := strconv.ParseFloat(strings.ReplaceAll(value, ",", "."), 64)
cur.Amount = math.Abs(amt)
if amt < 0 {
cur.Type = "expense"
} else {
cur.Type = "income"
}
}
case "FITID":
if cur != nil {
cur.ExternalID = value
}
case "MEMO":
if cur != nil && cur.Description == "" {
cur.Description = cleanDescription(value)
}
case "NAME":
if cur != nil {
cur.Description = cleanDescription(value)
}
}
}
if cur != nil {
rows = append(rows, *cur)
}
return rows, scanner.Err()
}
// splitOFXLine handles both "<TAG>value" and "TAG:value" formats.
func splitOFXLine(line string) (tag, value string) {
if strings.HasPrefix(line, "<") {
end := strings.Index(line, ">")
if end < 0 {
return "", ""
}
tag = strings.ToUpper(line[1:end])
value = strings.TrimSpace(line[end+1:])
return
}
// header lines like "OFXHEADER:100" — ignore
return "", ""
}
// parseOFXDate handles formats: 20060102, 20060102120000, 20060102120000[-3:BRT]
func parseOFXDate(s string) string {
s = strings.SplitN(s, "[", 2)[0] // strip timezone suffix
s = strings.TrimSpace(s)
formats := []string{"20060102150405", "20060102"}
for _, f := range formats {
if len(s) >= len(f) {
t, err := time.Parse(f, s[:len(f)])
if err == nil {
return t.Format("2006-01-02")
}
}
}
return s
}
func cleanDescription(s string) string {
s = strings.TrimSpace(s)
// remove duplicate whitespace
fields := strings.Fields(s)
return strings.Join(fields, " ")
}
+136
View File
@@ -0,0 +1,136 @@
package parser_test
import (
"strings"
"testing"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/parser"
)
const sampleOFX = `OFXHEADER:100
DATA:OFXSGML
VERSION:150
<OFX>
<BANKMSGSRSV1>
<STMTTRNRS>
<STMTRS>
<BANKTRANLIST>
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20240315120000[-3:BRT]
<TRNAMT>-150.90
<FITID>2024031500001
<MEMO>PAGTO BOLETO ENERGIA
</STMTTRN>
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20240320
<TRNAMT>5000.00
<FITID>2024032000001
<MEMO>SALARIO EMPRESA
</STMTTRN>
</BANKTRANLIST>
`
func TestParseOFX_BasicFields(t *testing.T) {
rows, err := parser.ParseOFX(strings.NewReader(sampleOFX))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
debit := rows[0]
if debit.Date != "2024-03-15" {
t.Errorf("date: want 2024-03-15, got %q", debit.Date)
}
if debit.Amount != 150.90 {
t.Errorf("amount: want 150.90, got %v", debit.Amount)
}
if debit.Type != "expense" {
t.Errorf("type: want expense, got %q", debit.Type)
}
if debit.ExternalID != "2024031500001" {
t.Errorf("external_id: got %q", debit.ExternalID)
}
if debit.Description != "PAGTO BOLETO ENERGIA" {
t.Errorf("desc: got %q", debit.Description)
}
credit := rows[1]
if credit.Type != "income" {
t.Errorf("type: want income, got %q", credit.Type)
}
if credit.Amount != 5000.00 {
t.Errorf("amount: want 5000.00, got %v", credit.Amount)
}
}
func TestParseOFX_EmptyFile(t *testing.T) {
rows, err := parser.ParseOFX(strings.NewReader(""))
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 rows, got %d", len(rows))
}
}
func TestParseCSV_Semicolon(t *testing.T) {
// Brazilian bank CSV: semicolon separator, comma as decimal
data := "15/03/2024;-150,90;SUPERMERCADO\n20/03/2024;5000,00;SALARIO\n"
m := model.CSVMapping{
DateColumn: 0,
AmountColumn: 1,
DescriptionColumn: 2,
DateFormat: "02/01/2006",
HasHeader: false,
DecimalSeparator: ",",
FieldSeparator: ";",
}
rows, errs := parser.ParseCSV(strings.NewReader(data), m)
if len(errs) != 0 {
t.Fatalf("unexpected errors: %v", errs)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].Date != "2024-03-15" {
t.Errorf("date: got %q", rows[0].Date)
}
if rows[0].Amount != 150.90 {
t.Errorf("amount: want 150.90, got %v", rows[0].Amount)
}
if rows[0].Type != "expense" {
t.Errorf("type: got %q", rows[0].Type)
}
if rows[1].Type != "income" {
t.Errorf("income type: got %q", rows[1].Type)
}
}
func TestParseCSV_WithHeader(t *testing.T) {
data := "Data;Valor;Descricao\n15/03/2024;-50,00;FARMACIA\n"
m := model.CSVMapping{
DateColumn: 0,
AmountColumn: 1,
DescriptionColumn: 2,
DateFormat: "02/01/2006",
HasHeader: true,
DecimalSeparator: ",",
FieldSeparator: ";",
}
rows, errs := parser.ParseCSV(strings.NewReader(data), m)
if len(errs) != 0 {
t.Fatalf("errors: %v", errs)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row (header skipped), got %d", len(rows))
}
if rows[0].Description != "FARMACIA" {
t.Errorf("desc: got %q", rows[0].Description)
}
}