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
+7
View File
@@ -52,6 +52,10 @@ func main() {
categorySvc := service.NewCategoryService(categoryRepo)
categoryHandler := handler.NewCategoryHandler(categorySvc)
transactionRepo := repository.NewTransactionRepository(pool)
importSvc := service.NewImportService(transactionRepo)
importHandler := handler.NewImportHandler(importSvc)
r.Get("/health", handler.Health)
r.Route("/api", func(r chi.Router) {
@@ -59,6 +63,9 @@ func main() {
r.Post("/categories", categoryHandler.Create)
r.Put("/categories/{id}", categoryHandler.Update)
r.Delete("/categories/{id}", categoryHandler.Delete)
r.Post("/imports/preview", importHandler.Preview)
r.Post("/imports/confirm", importHandler.Confirm)
})
// Serve Vue SPA — non-API routes fall through to index.html
+82
View File
@@ -0,0 +1,82 @@
package handler
import (
"encoding/json"
"net/http"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/service"
)
const maxUploadSize = 10 << 20 // 10 MB
type ImportHandler struct {
svc *service.ImportService
}
func NewImportHandler(svc *service.ImportService) *ImportHandler {
return &ImportHandler{svc: svc}
}
// Preview parses the uploaded file and returns rows with duplicate flags.
// POST /api/imports/preview multipart: file + optional csv_mapping JSON field
func (h *ImportHandler) Preview(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
respondError(w, http.StatusBadRequest, "file too large or invalid form")
return
}
file, header, err := r.FormFile("file")
if err != nil {
respondError(w, http.StatusBadRequest, "field 'file' is required")
return
}
defer file.Close()
var csvMapping *model.CSVMapping
if raw := r.FormValue("csv_mapping"); raw != "" {
var m model.CSVMapping
if err := json.Unmarshal([]byte(raw), &m); err != nil {
respondError(w, http.StatusBadRequest, "invalid csv_mapping JSON")
return
}
csvMapping = &m
}
rows, parseErrs, err := h.svc.Preview(r.Context(), header.Filename, file, csvMapping)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"parse_errors": parseErrs,
})
}
// Confirm saves the rows provided in the request body (after user review).
// POST /api/imports/confirm
func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
var body struct {
Filename string `json:"filename"`
Rows []model.ImportRow `json:"rows"`
ParseErrCount int `json:"parse_error_count"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Filename == "" {
respondError(w, http.StatusBadRequest, "filename is required")
return
}
result, err := h.svc.Confirm(r.Context(), body.Filename, body.Rows, body.ParseErrCount)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to save transactions")
return
}
respondJSON(w, http.StatusOK, result)
}
+14 -3
View File
@@ -9,11 +9,22 @@ import (
)
//go:embed sql/001_initial.sql
var initial string
var m001 string
//go:embed sql/002_add_external_id.sql
var m002 string
func Run(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, initial); err != nil {
return fmt.Errorf("001_initial: %w", err)
for i, sql := range []struct {
version int
sql string
}{
{1, m001},
{2, m002},
} {
if _, err := pool.Exec(ctx, sql.sql); err != nil {
return fmt.Errorf("migration %03d: %w", i+1, err)
}
}
return nil
}
@@ -0,0 +1,59 @@
CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
color VARCHAR(7) NOT NULL DEFAULT '#6B7280',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS transactions (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
description TEXT NOT NULL,
type VARCHAR(10) NOT NULL CHECK (type IN ('income', 'expense')),
source VARCHAR(10) NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'import')),
category_id INTEGER REFERENCES categories (id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS recurring_expenses (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
expected_amount NUMERIC(12, 2) NOT NULL,
day_of_month INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31),
category_id INTEGER REFERENCES categories (id) ON DELETE SET NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS import_logs (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
format VARCHAR(10) NOT NULL CHECK (format IN ('ofx', 'csv')),
imported_count INTEGER NOT NULL DEFAULT 0,
duplicate_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO categories (name, color, is_default)
VALUES
('Saúde / Insulina', '#EF4444', TRUE),
('Veleiro', '#3B82F6', TRUE),
('Jogos de Tabuleiro', '#8B5CF6', TRUE),
('Alimentação', '#F59E0B', TRUE),
('Lazer', '#10B981', TRUE),
('Freelance', '#6366F1', TRUE),
('Outros', '#6B7280', TRUE)
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
@@ -0,0 +1,4 @@
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS external_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS transactions_external_id_idx ON transactions (external_id) WHERE external_id IS NOT NULL;
INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
+43
View File
@@ -0,0 +1,43 @@
package model
import "time"
type Transaction struct {
ID int `json:"id"`
Date string `json:"date"` // YYYY-MM-DD
Amount float64 `json:"amount"`
Description string `json:"description"`
Type string `json:"type"` // income | expense
Source string `json:"source"` // manual | import
CategoryID *int `json:"category_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ImportRow is a parsed-but-not-yet-saved transaction from a file.
type ImportRow struct {
Date string `json:"date"`
Amount float64 `json:"amount"`
Description string `json:"description"`
Type string `json:"type"`
ExternalID string `json:"external_id,omitempty"` // FITID from OFX
IsDuplicate bool `json:"is_duplicate"`
}
// ImportResult is the response after confirming an import.
type ImportResult struct {
Imported int `json:"imported"`
Duplicates int `json:"duplicates"`
Errors int `json:"errors"`
}
// CSVMapping tells the parser which CSV column index holds each field.
type CSVMapping struct {
DateColumn int `json:"date_column"`
AmountColumn int `json:"amount_column"`
DescriptionColumn int `json:"description_column"`
DateFormat string `json:"date_format"` // Go time format, e.g. "02/01/2006"
HasHeader bool `json:"has_header"`
DecimalSeparator string `json:"decimal_separator"` // "." or ","
FieldSeparator string `json:"field_separator"` // "," or ";"
}
+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)
}
}
@@ -0,0 +1,83 @@
package repository
import (
"context"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/model"
)
type TransactionRepository interface {
IsDuplicate(ctx context.Context, date, description string, amount float64) (bool, error)
IsExternalIDKnown(ctx context.Context, externalID string) (bool, error)
BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error)
SaveImportLog(ctx context.Context, filename, format string, imported, duplicates, errors int) error
}
type transactionRepo struct{ db *pgxpool.Pool }
func NewTransactionRepository(db *pgxpool.Pool) TransactionRepository {
return &transactionRepo{db: db}
}
// normalizeDesc lowercases and collapses whitespace for fuzzy dedup.
func normalizeDesc(s string) string {
return strings.ToLower(strings.Join(strings.Fields(s), " "))
}
func (r *transactionRepo) IsDuplicate(ctx context.Context, date, description string, amount float64) (bool, error) {
var count int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM transactions
WHERE date = $1 AND amount = $2 AND LOWER(description) = $3`,
date, amount, normalizeDesc(description)).Scan(&count)
return count > 0, err
}
func (r *transactionRepo) IsExternalIDKnown(ctx context.Context, externalID string) (bool, error) {
if externalID == "" {
return false, nil
}
var count int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM transactions WHERE external_id = $1`, externalID).Scan(&count)
return count > 0, err
}
func (r *transactionRepo) BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
count := 0
for _, row := range rows {
if row.IsDuplicate {
continue
}
var extID *string
if row.ExternalID != "" {
extID = &row.ExternalID
}
_, err := tx.Exec(ctx, `
INSERT INTO transactions (date, amount, description, type, source, external_id)
VALUES ($1, $2, $3, $4, 'import', $5)`,
row.Date, row.Amount, row.Description, row.Type, extID)
if err != nil {
return count, err
}
count++
}
return count, tx.Commit(ctx)
}
func (r *transactionRepo) SaveImportLog(ctx context.Context, filename, format string, imported, duplicates, errors int) error {
_, err := r.db.Exec(ctx, `
INSERT INTO import_logs (filename, format, imported_count, duplicate_count, error_count)
VALUES ($1, $2, $3, $4, $5)`,
filename, format, imported, duplicates, errors)
return err
}
+98
View File
@@ -0,0 +1,98 @@
package service
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/parser"
"financeiro-carvalho/internal/repository"
)
type ImportService struct {
repo repository.TransactionRepository
}
func NewImportService(repo repository.TransactionRepository) *ImportService {
return &ImportService{repo: repo}
}
// Preview parses the file and marks duplicates without saving anything.
func (s *ImportService) Preview(ctx context.Context, filename string, r io.Reader, csvMapping *model.CSVMapping) ([]model.ImportRow, []string, error) {
rows, parseErrs, err := s.parse(filename, r, csvMapping)
if err != nil {
return nil, nil, err
}
for i := range rows {
dup, err := s.isDuplicate(ctx, &rows[i])
if err != nil {
return nil, nil, fmt.Errorf("dedup check: %w", err)
}
rows[i].IsDuplicate = dup
}
return rows, parseErrs, nil
}
// Confirm saves the non-duplicate rows and logs the operation.
func (s *ImportService) Confirm(ctx context.Context, filename string, rows []model.ImportRow, parseErrCount int) (model.ImportResult, error) {
imported, err := s.repo.BulkInsert(ctx, rows)
if err != nil {
return model.ImportResult{}, err
}
duplicates := 0
for _, r := range rows {
if r.IsDuplicate {
duplicates++
}
}
format := strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".")
if format != "ofx" && format != "csv" {
format = "csv"
}
_ = s.repo.SaveImportLog(ctx, filename, format, imported, duplicates, parseErrCount)
return model.ImportResult{
Imported: imported,
Duplicates: duplicates,
Errors: parseErrCount,
}, nil
}
func (s *ImportService) parse(filename string, r io.Reader, csvMapping *model.CSVMapping) ([]model.ImportRow, []string, error) {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".ofx":
rows, err := parser.ParseOFX(r)
return rows, nil, err
case ".csv":
m := model.CSVMapping{
DateColumn: 0,
AmountColumn: 1,
DescriptionColumn: 2,
DateFormat: "02/01/2006",
HasHeader: true,
DecimalSeparator: ",",
FieldSeparator: ";",
}
if csvMapping != nil {
m = *csvMapping
}
rows, errs := parser.ParseCSV(r, m)
return rows, errs, nil
default:
return nil, nil, fmt.Errorf("unsupported file format: %q (use .ofx or .csv)", ext)
}
}
func (s *ImportService) isDuplicate(ctx context.Context, row *model.ImportRow) (bool, error) {
if row.ExternalID != "" {
return s.repo.IsExternalIDKnown(ctx, row.ExternalID)
}
return s.repo.IsDuplicate(ctx, row.Date, row.Description, row.Amount)
}