diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 3c86665..cdeb619 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -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 diff --git a/apps/api/internal/handler/import.go b/apps/api/internal/handler/import.go new file mode 100644 index 0000000..230c48a --- /dev/null +++ b/apps/api/internal/handler/import.go @@ -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) +} diff --git a/apps/api/internal/migration/migration.go b/apps/api/internal/migration/migration.go index d7f990c..4ea9769 100644 --- a/apps/api/internal/migration/migration.go +++ b/apps/api/internal/migration/migration.go @@ -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 } diff --git a/apps/api/internal/migration/sql/001_initial.sql.bak b/apps/api/internal/migration/sql/001_initial.sql.bak new file mode 100644 index 0000000..7e83536 --- /dev/null +++ b/apps/api/internal/migration/sql/001_initial.sql.bak @@ -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; diff --git a/apps/api/internal/migration/sql/002_add_external_id.sql b/apps/api/internal/migration/sql/002_add_external_id.sql new file mode 100644 index 0000000..b957c87 --- /dev/null +++ b/apps/api/internal/migration/sql/002_add_external_id.sql @@ -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; diff --git a/apps/api/internal/model/transaction.go b/apps/api/internal/model/transaction.go new file mode 100644 index 0000000..ce2645b --- /dev/null +++ b/apps/api/internal/model/transaction.go @@ -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 ";" +} diff --git a/apps/api/internal/parser/csv.go b/apps/api/internal/parser/csv.go new file mode 100644 index 0000000..0852780 --- /dev/null +++ b/apps/api/internal/parser/csv.go @@ -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 +} diff --git a/apps/api/internal/parser/ofx.go b/apps/api/internal/parser/ofx.go new file mode 100644 index 0000000..46adfbe --- /dev/null +++ b/apps/api/internal/parser/ofx.go @@ -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 "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, " ") +} diff --git a/apps/api/internal/parser/ofx_test.go b/apps/api/internal/parser/ofx_test.go new file mode 100644 index 0000000..daa9929 --- /dev/null +++ b/apps/api/internal/parser/ofx_test.go @@ -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 + + + + + + + +DEBIT +20240315120000[-3:BRT] +-150.90 +2024031500001 +PAGTO BOLETO ENERGIA + + +CREDIT +20240320 +5000.00 +2024032000001 +SALARIO EMPRESA + + +` + +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) + } +} diff --git a/apps/api/internal/repository/transaction.go b/apps/api/internal/repository/transaction.go new file mode 100644 index 0000000..99f0e29 --- /dev/null +++ b/apps/api/internal/repository/transaction.go @@ -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 +} diff --git a/apps/api/internal/service/import.go b/apps/api/internal/service/import.go new file mode 100644 index 0000000..bd8eed0 --- /dev/null +++ b/apps/api/internal/service/import.go @@ -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) +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 3ba4774..75efbdf 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -7,6 +7,7 @@ diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index bc11dc7..7a98f5c 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -14,6 +14,11 @@ const router = createRouter({ name: 'categories', component: () => import('../views/CategoriesView.vue'), }, + { + path: '/importar', + name: 'import', + component: () => import('../views/ImportView.vue'), + }, ], }) diff --git a/apps/web/src/stores/import.ts b/apps/web/src/stores/import.ts new file mode 100644 index 0000000..1b3ffb5 --- /dev/null +++ b/apps/web/src/stores/import.ts @@ -0,0 +1,96 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface ImportRow { + date: string + amount: number + description: string + type: string + external_id?: string + is_duplicate: boolean +} + +export interface ImportResult { + imported: number + duplicates: number + errors: number +} + +export interface CSVMapping { + date_column: number + amount_column: number + description_column: number + date_format: string + has_header: boolean + decimal_separator: string + field_separator: string +} + +export const useImportStore = defineStore('import', () => { + const rows = ref([]) + const parseErrors = ref([]) + const filename = ref('') + const loading = ref(false) + const error = ref(null) + const result = ref(null) + + async function preview(file: File, csvMapping?: CSVMapping) { + loading.value = true + error.value = null + result.value = null + filename.value = file.name + + const form = new FormData() + form.append('file', file) + if (csvMapping) { + form.append('csv_mapping', JSON.stringify(csvMapping)) + } + + try { + const res = await fetch('/api/imports/preview', { method: 'POST', body: form }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? 'Erro no preview') + rows.value = data.rows ?? [] + parseErrors.value = data.parse_errors ?? [] + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function confirm() { + loading.value = true + error.value = null + try { + const res = await fetch('/api/imports/confirm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: filename.value, + rows: rows.value, + parse_error_count: parseErrors.value.length, + }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? 'Erro ao confirmar') + result.value = data + rows.value = [] + parseErrors.value = [] + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + function reset() { + rows.value = [] + parseErrors.value = [] + filename.value = '' + error.value = null + result.value = null + } + + return { rows, parseErrors, filename, loading, error, result, preview, confirm, reset } +}) diff --git a/apps/web/src/views/ImportView.vue b/apps/web/src/views/ImportView.vue new file mode 100644 index 0000000..87c6a17 --- /dev/null +++ b/apps/web/src/views/ImportView.vue @@ -0,0 +1,224 @@ + + + + +