308 lines
7.8 KiB
Go
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)
|
|
}
|