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 + +