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]>
115 lines
2.5 KiB
Go
115 lines
2.5 KiB
Go
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, " ")
|
|
}
|