up
This commit is contained in:
307
internal/importer/qif.go
Normal file
307
internal/importer/qif.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ── Modèles ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type QIFTransaction struct {
|
||||
Date string `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Payee string `json:"payee"`
|
||||
Memo string `json:"memo"`
|
||||
Type string `json:"type"` // income | expense
|
||||
CategoryID string `json:"category_id"`
|
||||
PropertyID string `json:"property_id"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ImportResult struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ── Parser QIF ────────────────────────────────────────────────────────────────
|
||||
|
||||
func ParseQIF(r io.Reader) ([]QIFTransaction, error) {
|
||||
var transactions []QIFTransaction
|
||||
var current QIFTransaction
|
||||
inTransaction := false
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "!") {
|
||||
continue
|
||||
}
|
||||
|
||||
code := string(line[0])
|
||||
value := ""
|
||||
if len(line) > 1 {
|
||||
value = strings.TrimSpace(line[1:])
|
||||
}
|
||||
|
||||
switch code {
|
||||
case "D":
|
||||
inTransaction = true
|
||||
current.Date = parseQIFDate(value)
|
||||
|
||||
case "T", "U":
|
||||
amount, err := parseQIFAmount(value)
|
||||
if err == nil {
|
||||
current.Amount = amount
|
||||
if amount >= 0 {
|
||||
current.Type = "income"
|
||||
} else {
|
||||
current.Type = "expense"
|
||||
current.Amount = -amount
|
||||
}
|
||||
}
|
||||
|
||||
case "P":
|
||||
current.Payee = value
|
||||
|
||||
case "M":
|
||||
current.Memo = value
|
||||
|
||||
case "^":
|
||||
if inTransaction && current.Date != "" {
|
||||
desc := current.Payee
|
||||
if current.Memo != "" && current.Memo != current.Payee {
|
||||
if desc != "" {
|
||||
desc += " — " + current.Memo
|
||||
} else {
|
||||
desc = current.Memo
|
||||
}
|
||||
}
|
||||
current.Description = desc
|
||||
transactions = append(transactions, current)
|
||||
current = QIFTransaction{}
|
||||
inTransaction = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inTransaction && current.Date != "" {
|
||||
desc := current.Payee
|
||||
if current.Memo != "" && current.Memo != current.Payee {
|
||||
if desc != "" {
|
||||
desc += " — " + current.Memo
|
||||
} else {
|
||||
desc = current.Memo
|
||||
}
|
||||
}
|
||||
current.Description = desc
|
||||
transactions = append(transactions, current)
|
||||
}
|
||||
|
||||
return transactions, scanner.Err()
|
||||
}
|
||||
|
||||
// parseQIFDate priorité DD/MM/YYYY (format français)
|
||||
func parseQIFDate(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ReplaceAll(s, "'", "/")
|
||||
|
||||
// Détecter séparateur
|
||||
sep := "/"
|
||||
if strings.Contains(s, "-") && !strings.Contains(s, "/") {
|
||||
sep = "-"
|
||||
}
|
||||
|
||||
parts := strings.Split(s, sep)
|
||||
if len(parts) != 3 {
|
||||
return s
|
||||
}
|
||||
|
||||
p0 := strings.TrimSpace(parts[0])
|
||||
p1 := strings.TrimSpace(parts[1])
|
||||
p2 := strings.TrimSpace(parts[2])
|
||||
|
||||
// YYYY-MM-DD ou YYYY/MM/DD
|
||||
if len(p0) == 4 {
|
||||
return fmt.Sprintf("%s-%s-%s", p0, zeroPad(p1), zeroPad(p2))
|
||||
}
|
||||
|
||||
// DD/MM/YYYY ou DD/MM/YY
|
||||
year := ""
|
||||
day := p0
|
||||
month := p1
|
||||
|
||||
if len(p2) == 4 {
|
||||
year = p2
|
||||
} else if len(p2) == 2 {
|
||||
y, _ := strconv.Atoi(p2)
|
||||
if y <= 50 {
|
||||
year = fmt.Sprintf("20%02d", y)
|
||||
} else {
|
||||
year = fmt.Sprintf("19%02d", y)
|
||||
}
|
||||
}
|
||||
|
||||
if year == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
// Si p0 > 12, c'est forcément le jour (format français DD/MM)
|
||||
d, _ := strconv.Atoi(p0)
|
||||
m, _ := strconv.Atoi(p1)
|
||||
if d > 12 {
|
||||
// Clairement DD/MM/YYYY
|
||||
return fmt.Sprintf("%s-%s-%s", year, zeroPad(month), zeroPad(day))
|
||||
}
|
||||
if m > 12 {
|
||||
// Clairement MM/DD → p1 est le jour
|
||||
return fmt.Sprintf("%s-%s-%s", year, zeroPad(day), zeroPad(month))
|
||||
}
|
||||
// Ambiguïté : on assume DD/MM (format français)
|
||||
return fmt.Sprintf("%s-%s-%s", year, zeroPad(month), zeroPad(day))
|
||||
}
|
||||
|
||||
func zeroPad(s string) string {
|
||||
if len(s) == 1 {
|
||||
return "0" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseQIFAmount gère 1.234,56 et 1,234.56 et -1234.56
|
||||
func parseQIFAmount(s string) (float64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, "€", "")
|
||||
s = strings.ReplaceAll(s, "$", "")
|
||||
|
||||
if strings.Contains(s, ",") && strings.Contains(s, ".") {
|
||||
if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") {
|
||||
s = strings.ReplaceAll(s, ".", "")
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
}
|
||||
} else if strings.Contains(s, ",") {
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
}
|
||||
|
||||
return strconv.ParseFloat(s, 64)
|
||||
}
|
||||
|
||||
// ── Store & Handler ───────────────────────────────────────────────────────────
|
||||
|
||||
type Handler struct{ db *sql.DB }
|
||||
|
||||
func NewHandler(db *sql.DB) *Handler { return &Handler{db: db} }
|
||||
|
||||
// Check retourne pour chaque transaction si elle existe déjà en base
|
||||
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
var payload []QIFTransaction
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
exists := make([]bool, len(payload))
|
||||
for i, t := range payload {
|
||||
// 1. Correspondance exacte (date + montant + type)
|
||||
var exactCount int
|
||||
h.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM transactions WHERE date=? AND amount=? AND type=?`,
|
||||
t.Date, t.Amount, t.Type,
|
||||
).Scan(&exactCount)
|
||||
if exactCount > 0 {
|
||||
exists[i] = true
|
||||
continue
|
||||
}
|
||||
// 2. Cas split en 2 parts (intérêts + capital) : chercher une paire dont la somme = montant
|
||||
var pairCount int
|
||||
h.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM transactions t1
|
||||
JOIN transactions t2 ON t1.date=t2.date AND t1.id<t2.id AND t1.type=t2.type
|
||||
WHERE t1.date=? AND t1.type=? AND ABS(t1.amount+t2.amount-?)<0.10`,
|
||||
t.Date, t.Type, t.Amount,
|
||||
).Scan(&pairCount)
|
||||
exists[i] = pairCount > 0
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(exists)
|
||||
}
|
||||
|
||||
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseMultipartForm(10 << 20)
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "fichier requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
txs, err := ParseQIF(file)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("erreur parsing QIF: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(txs)
|
||||
}
|
||||
|
||||
func (h *Handler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
PropertyID string `json:"property_id"`
|
||||
Transactions []QIFTransaction `json:"transactions"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.PropertyID == "" {
|
||||
http.Error(w, "property_id requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result := ImportResult{}
|
||||
for _, t := range payload.Transactions {
|
||||
result.Total++
|
||||
// Déduplication : même date + montant + type + description + bien
|
||||
var existing int
|
||||
h.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM transactions WHERE date=? AND amount=? AND type=?`,
|
||||
t.Date, t.Amount, t.Type,
|
||||
).Scan(&existing)
|
||||
if existing > 0 {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
var catID interface{}
|
||||
if t.CategoryID != "" {
|
||||
catID = t.CategoryID
|
||||
}
|
||||
_, err := h.db.Exec(
|
||||
`INSERT INTO transactions (id, property_id, category_id, type, amount, date, description) VALUES (?,?,?,?,?,?,?)`,
|
||||
uuid.NewString(), t.PropertyID, catID, t.Type, t.Amount, t.Date, t.Description,
|
||||
)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", t.Date, err))
|
||||
} else {
|
||||
result.Imported++
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
Reference in New Issue
Block a user