Files
RentalManager/internal/importer/qif.go
2026-04-11 12:12:07 +02:00

308 lines
7.8 KiB
Go

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)
}