Gonçalo Rodrigues 13b7149614 First Commit
2026-06-13 11:25:23 +01:00

276 lines
6.2 KiB
Go

package main
import (
"encoding/csv"
"fmt"
"io"
"math"
"strconv"
"strings"
"time"
)
type CSVFormat string
const (
FormatCGD CSVFormat = "cgd"
FormatTradeRepublic CSVFormat = "traderepublic"
FormatGeneric CSVFormat = "generic"
)
type CSVColumnMapping struct {
DateCol int
DescriptionCol int
AmountCol int
TypeCol int // debit/credit column index; ≥0 means split debit/credit (AmountCol=debit, TypeCol=credit)
HasHeader bool
DateFormat string
SkipRows int
DecimalSep string // "." or ","
// TypeCol is -1 when unused; set it explicitly in the mapping.
}
var CGDMapping = CSVColumnMapping{
DateCol: 0,
DescriptionCol: 2,
AmountCol: 3,
TypeCol: 4, // Crédito column; AmountCol (3) is Débito (negative), TypeCol (4) is Crédito (positive)
HasHeader: true,
DateFormat: "02-01-2006",
DecimalSep: ",",
}
var TradeRepublicMapping = CSVColumnMapping{
DateCol: 1,
DescriptionCol: 17,
AmountCol: 10,
TypeCol: -1,
HasHeader: true,
DateFormat: "2006-01-02",
DecimalSep: ".",
}
func parseCSV(r io.Reader, mapping CSVColumnMapping) ([]CSVImportRow, error) {
reader := csv.NewReader(r)
reader.LazyQuotes = true
reader.FieldsPerRecord = -1
if mapping.DecimalSep == "," {
reader.Comma = ';'
} else {
reader.Comma = ','
}
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("read csv: %w", err)
}
var rows []CSVImportRow
for _, rec := range records {
maxCol := mapping.AmountCol
if mapping.TypeCol >= 0 && mapping.TypeCol > maxCol {
maxCol = mapping.TypeCol
}
if len(rec) <= maxCol || len(rec) <= mapping.DateCol || len(rec) <= mapping.DescriptionCol {
continue
}
dateStr := strings.TrimSpace(rec[mapping.DateCol])
desc := strings.TrimSpace(rec[mapping.DescriptionCol])
if dateStr == "" || desc == "" {
continue
}
date, err := time.Parse(mapping.DateFormat, dateStr)
if err != nil {
date, err = time.Parse("2006-01-02", dateStr)
if err != nil {
date, err = time.Parse("02-01-2006", dateStr)
if err != nil {
continue
}
}
}
var amountStr string
negative := false
if mapping.TypeCol >= 0 {
amountStr = strings.TrimSpace(rec[mapping.TypeCol])
if amountStr != "" {
negative = false // Crédito = positive
} else {
amountStr = strings.TrimSpace(rec[mapping.AmountCol])
if amountStr == "" {
continue
}
negative = true // Débito = negative
}
} else {
amountStr = strings.TrimSpace(rec[mapping.AmountCol])
if amountStr == "" {
continue
}
}
cents, err := parseCents(amountStr, mapping.DecimalSep)
if err != nil {
continue
}
if negative {
cents = -cents
}
rows = append(rows, CSVImportRow{
Date: date.Format("2006-01-02"),
Description: desc,
AmountCents: cents,
})
}
if len(rows) == 0 {
return nil, fmt.Errorf("no valid rows found in CSV")
}
return rows, nil
}
func parseCents(s, decimalSep string) (int64, error) {
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "\u00a0", "")
s = strings.ReplaceAll(s, "€", "")
s = strings.TrimSpace(s)
negative := false
if strings.HasPrefix(s, "-") {
negative = true
s = s[1:]
}
if strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") {
negative = true
s = s[1 : len(s)-1]
}
if decimalSep == "," {
s = strings.ReplaceAll(s, ".", "")
s = strings.Replace(s, ",", ".", 1)
} else {
s = strings.ReplaceAll(s, ",", "")
}
amount, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, fmt.Errorf("parse amount %q: %w", s, err)
}
cents := int64(math.Round(amount * 100))
if negative {
cents = -cents
}
if cents == 0 {
cents = -cents
}
return cents, nil
}
type securitiesTradeRow struct {
Date string
Name string
ISIN string
Type string // buy/sell
Quantity float64
PriceCents int64
TotalCents int64
}
func parseSecuritiesCSV(r io.Reader) ([]securitiesTradeRow, error) {
reader := csv.NewReader(r)
reader.Comma = ','
reader.LazyQuotes = true
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("read securities csv: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("csv has no data rows")
}
headerMap := make(map[string]int)
for i, h := range records[0] {
headerMap[strings.ToLower(strings.TrimSpace(h))] = i
}
aliases := map[string]string{
"symbol": "isin",
"shares": "quantity",
"amount": "total",
}
colMap := map[string]string{
"date": "date",
"name": "name",
"isin": "isin",
"type": "type",
"quantity": "quantity",
"price": "price",
"total": "total",
"currency": "currency",
}
for _, h := range records[0] {
h = strings.ToLower(strings.TrimSpace(h))
if mapped, ok := aliases[h]; ok {
colMap[mapped] = h
} else if _, ok := colMap[h]; ok {
colMap[h] = h
}
}
for _, r := range []string{"date", "name", "isin", "type", "quantity", "price", "total", "currency"} {
if _, ok := headerMap[colMap[r]]; !ok {
return nil, fmt.Errorf("missing required column %q in securities CSV", r)
}
}
var trades []securitiesTradeRow
for i := 1; i < len(records); i++ {
rec := records[i]
if len(rec) <= headerMap[colMap["date"]] {
continue
}
t := securitiesTradeRow{
Date: strings.TrimSpace(rec[headerMap[colMap["date"]]]),
Name: strings.TrimSpace(rec[headerMap[colMap["name"]]]),
ISIN: strings.TrimSpace(rec[headerMap[colMap["isin"]]]),
Type: strings.ToLower(strings.TrimSpace(rec[headerMap[colMap["type"]]])),
}
if t.Date == "" || t.Name == "" || t.ISIN == "" {
continue
}
qtyStr := strings.TrimSpace(rec[headerMap[colMap["quantity"]]])
priceStr := strings.TrimSpace(rec[headerMap[colMap["price"]]])
totalStr := strings.TrimSpace(rec[headerMap[colMap["total"]]])
t.Quantity, _ = strconv.ParseFloat(qtyStr, 64)
t.PriceCents, _ = parseCents(priceStr, ".")
t.TotalCents, _ = parseCents(totalStr, ".")
if t.TotalCents < 0 {
t.TotalCents = -t.TotalCents
}
if t.Quantity == 0 && t.TotalCents == 0 {
continue
}
trades = append(trades, t)
}
if len(trades) == 0 {
return nil, fmt.Errorf("no valid trades found in securities CSV")
}
return trades, nil
}