From e438002c0a015802805a2ee3bec6db79cfb18f61 Mon Sep 17 00:00:00 2001 From: Mlcarvalho1 Date: Wed, 27 May 2026 15:56:14 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20importa=C3=A7=C3=A3o=20de=20fatura=20de?= =?UTF-8?q?=20cart=C3=A3o=20=E2=80=94=20strip=20R$,=20skip=20negativos,=20?= =?UTF-8?q?all=5Fexpenses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser limpa prefixo R$ e símbolos de moeda antes de parsear o valor. Flags SkipNegative e AllExpenses para CSV de fatura de cartão. UI: checkboxes "Fatura de cartão" e "Ignorar valores negativos". Co-Authored-By: Claude Sonnet 4.6 --- apps/api/internal/model/transaction.go | 2 ++ apps/api/internal/parser/csv.go | 28 +++++++++++++++++++++----- apps/web/src/stores/import.ts | 2 ++ apps/web/src/views/ImportView.vue | 10 +++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/api/internal/model/transaction.go b/apps/api/internal/model/transaction.go index 3f5d77d..f960ae7 100644 --- a/apps/api/internal/model/transaction.go +++ b/apps/api/internal/model/transaction.go @@ -41,4 +41,6 @@ type CSVMapping struct { HasHeader bool `json:"has_header"` DecimalSeparator string `json:"decimal_separator"` // "." or "," FieldSeparator string `json:"field_separator"` // "," or ";" + SkipNegative bool `json:"skip_negative"` // skip rows with negative amount (e.g. credit card bill payments) + AllExpenses bool `json:"all_expenses"` // treat all rows as expense (credit card statements) } diff --git a/apps/api/internal/parser/csv.go b/apps/api/internal/parser/csv.go index 0852780..6650c98 100644 --- a/apps/api/internal/parser/csv.go +++ b/apps/api/internal/parser/csv.go @@ -57,24 +57,42 @@ func ParseCSV(r io.Reader, m model.CSVMapping) ([]model.ImportRow, []string) { amtStr := strings.TrimSpace(record[m.AmountColumn]) desc := cleanDescription(record[m.DescriptionColumn]) + // Skip blank rows (e.g. empty line after header in some bank exports) + if dateStr == "" && amtStr == "" && desc == "" { + continue + } + t, err := time.Parse(m.DateFormat, dateStr) if err != nil { errs = append(errs, fmt.Sprintf("linha %d: data inválida %q", lineNum, dateStr)) continue } + // Strip currency symbols (R$, $, €, etc.) keeping digits, comma, dot, minus + cleaned := strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' || r == ',' || r == '.' || r == '-' { + return r + } + return -1 + }, amtStr) + cleaned = strings.TrimSpace(cleaned) + if m.DecimalSeparator == "," { - amtStr = strings.ReplaceAll(amtStr, ".", "") - amtStr = strings.ReplaceAll(amtStr, ",", ".") + cleaned = strings.ReplaceAll(cleaned, ".", "") + cleaned = strings.ReplaceAll(cleaned, ",", ".") } - amt, err := strconv.ParseFloat(amtStr, 64) - if err != nil { + amt, err := strconv.ParseFloat(cleaned, 64) + if err != nil || cleaned == "" { errs = append(errs, fmt.Sprintf("linha %d: valor inválido %q", lineNum, amtStr)) continue } + if m.SkipNegative && amt < 0 { + continue + } + txType := "expense" - if amt > 0 { + if !m.AllExpenses && amt > 0 { txType = "income" } diff --git a/apps/web/src/stores/import.ts b/apps/web/src/stores/import.ts index 1b3ffb5..51fdea7 100644 --- a/apps/web/src/stores/import.ts +++ b/apps/web/src/stores/import.ts @@ -24,6 +24,8 @@ export interface CSVMapping { has_header: boolean decimal_separator: string field_separator: string + skip_negative: boolean + all_expenses: boolean } export const useImportStore = defineStore('import', () => { diff --git a/apps/web/src/views/ImportView.vue b/apps/web/src/views/ImportView.vue index 5d717ef..cae2acc 100644 --- a/apps/web/src/views/ImportView.vue +++ b/apps/web/src/views/ImportView.vue @@ -17,6 +17,8 @@ const csvMapping = ref({ has_header: true, decimal_separator: ',', field_separator: ';', + skip_negative: false, + all_expenses: false, }) function onFileChange(e: Event) { @@ -130,6 +132,14 @@ function fmt(amount: number) { Arquivo tem cabeçalho + +