feat: importação de fatura de cartão — strip R$, skip negativos, all_expenses

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 <[email protected]>
This commit is contained in:
2026-05-27 15:56:14 -03:00
co-authored by Claude Sonnet 4.6
parent 7dae05cabf
commit e438002c0a
4 changed files with 37 additions and 5 deletions
+2
View File
@@ -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)
}
+23 -5
View File
@@ -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"
}
+2
View File
@@ -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', () => {
+10
View File
@@ -17,6 +17,8 @@ const csvMapping = ref<CSVMapping>({
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) {
<input type="checkbox" v-model="csvMapping.has_header" />
Arquivo tem cabeçalho
</label>
<label class="fc-label fc-import-cfg__checkbox">
<input type="checkbox" v-model="csvMapping.all_expenses" />
Fatura de cartão (todos são despesas)
</label>
<label class="fc-label fc-import-cfg__checkbox">
<input type="checkbox" v-model="csvMapping.skip_negative" />
Ignorar valores negativos
</label>
</div>
</NeonPanel>