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 "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, " ") }