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]>
137 lines
3.1 KiB
Go
137 lines
3.1 KiB
Go
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)
|
|
}
|
|
}
|