276 lines
6.2 KiB
Go
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
|
|
}
|