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