package loan import ( "database/sql" "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/google/uuid" "github.com/gorilla/mux" ) // ── Modèles ─────────────────────────────────────────────────────────────────── type Loan struct { ID string `json:"id"` PropertyID string `json:"property_id"` Label string `json:"label"` Reference string `json:"reference"` InitialAmount float64 `json:"initial_amount"` MonthlyPayment float64 `json:"monthly_payment"` PropertyName string `json:"property_name,omitempty"` } type LoanLine struct { ID string `json:"id"` LoanID string `json:"loan_id"` Rank int `json:"rank"` DueDate string `json:"due_date"` TotalAmount float64 `json:"total_amount"` Capital float64 `json:"capital"` Interest float64 `json:"interest"` RemainingCapital float64 `json:"remaining_capital"` } // ── Store ───────────────────────────────────────────────────────────────────── type Store struct{ db *sql.DB } func NewStore(db *sql.DB) *Store { return &Store{db: db} } func (s *Store) Migrate() error { if _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS loans ( id TEXT PRIMARY KEY, property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE, label TEXT NOT NULL, reference TEXT, initial_amount REAL NOT NULL, monthly_payment REAL NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS loan_lines ( id TEXT PRIMARY KEY, loan_id TEXT NOT NULL REFERENCES loans(id) ON DELETE CASCADE, rank INTEGER NOT NULL, due_date DATE NOT NULL, total_amount REAL, capital REAL NOT NULL DEFAULT 0, interest REAL NOT NULL DEFAULT 0, remaining_capital REAL NOT NULL DEFAULT 0, UNIQUE(loan_id, rank) );`); err != nil { return err } return s.patchSeedLines() } // patchSeedLines recharge les lignes depuis le seed pour tout prêt dont // le nombre de lignes en base est inférieur au seed (données manquantes). func (s *Store) patchSeedLines() error { rows, err := s.db.Query(`SELECT id, COALESCE(reference,'') FROM loans`) if err != nil { return err } defer rows.Close() for rows.Next() { var id, ref string rows.Scan(&id, &ref) var seedLines []LoanLine switch { case strings.Contains(ref, "781495"): seedLines = GetLoan781495Lines() case strings.Contains(ref, "781728"): seedLines = GetLoan781728Lines() default: continue } var count int s.db.QueryRow(`SELECT COUNT(*) FROM loan_lines WHERE loan_id=?`, id).Scan(&count) if count < len(seedLines) { s.InsertLines(id, seedLines) } } return nil } func (s *Store) ListLoans(propertyID string) ([]Loan, error) { query := `SELECT l.id, l.property_id, l.label, COALESCE(l.reference,''), l.initial_amount, l.monthly_payment, p.name FROM loans l JOIN properties p ON p.id = l.property_id WHERE 1=1` args := []any{} if propertyID != "" { query += " AND l.property_id=?" args = append(args, propertyID) } rows, err := s.db.Query(query+" ORDER BY l.label", args...) if err != nil { return nil, err } defer rows.Close() var loans []Loan for rows.Next() { var l Loan rows.Scan(&l.ID, &l.PropertyID, &l.Label, &l.Reference, &l.InitialAmount, &l.MonthlyPayment, &l.PropertyName) loans = append(loans, l) } return loans, nil } func (s *Store) CreateLoan(l *Loan) error { l.ID = uuid.NewString() _, err := s.db.Exec( `INSERT INTO loans (id, property_id, label, reference, initial_amount, monthly_payment) VALUES (?,?,?,?,?,?)`, l.ID, l.PropertyID, l.Label, l.Reference, l.InitialAmount, l.MonthlyPayment, ) return err } func (s *Store) DeleteLoan(id string) error { _, err := s.db.Exec(`DELETE FROM loans WHERE id=?`, id) return err } func (s *Store) InsertLines(loanID string, lines []LoanLine) error { tx, err := s.db.Begin() if err != nil { return err } // Supprimer les anciennes lignes tx.Exec(`DELETE FROM loan_lines WHERE loan_id=?`, loanID) stmt, err := tx.Prepare(`INSERT INTO loan_lines (id, loan_id, rank, due_date, total_amount, capital, interest, remaining_capital) VALUES (?,?,?,?,?,?,?,?)`) if err != nil { tx.Rollback() return err } defer stmt.Close() for _, l := range lines { if _, err := stmt.Exec(uuid.NewString(), loanID, l.Rank, l.DueDate, l.TotalAmount, l.Capital, l.Interest, l.RemainingCapital); err != nil { tx.Rollback() return err } } return tx.Commit() } // GetLineByDate retourne la ligne d'amortissement pour une date donnée func (s *Store) GetLineByDate(loanID, date string) (*LoanLine, error) { var l LoanLine err := s.db.QueryRow(`SELECT id, loan_id, rank, due_date, total_amount, capital, interest, remaining_capital FROM loan_lines WHERE loan_id=? AND due_date=?`, loanID, date). Scan(&l.ID, &l.LoanID, &l.Rank, &l.DueDate, &l.TotalAmount, &l.Capital, &l.Interest, &l.RemainingCapital) if err != nil { return nil, err } return &l, nil } // GetLinesByYear retourne toutes les lignes d'une année func (s *Store) GetLinesByYear(loanID, year string) ([]LoanLine, error) { rows, err := s.db.Query(`SELECT id, loan_id, rank, due_date, total_amount, capital, interest, remaining_capital FROM loan_lines WHERE loan_id=? AND strftime('%Y', due_date)=? ORDER BY due_date`, loanID, year) if err != nil { return nil, err } defer rows.Close() var lines []LoanLine for rows.Next() { var l LoanLine rows.Scan(&l.ID, &l.LoanID, &l.Rank, &l.DueDate, &l.TotalAmount, &l.Capital, &l.Interest, &l.RemainingCapital) lines = append(lines, l) } return lines, nil } func (s *Store) GetLines(loanID string) ([]LoanLine, error) { rows, err := s.db.Query(`SELECT id, loan_id, rank, due_date, total_amount, capital, interest, remaining_capital FROM loan_lines WHERE loan_id=? ORDER BY rank`, loanID) if err != nil { return nil, err } defer rows.Close() var lines []LoanLine for rows.Next() { var l LoanLine rows.Scan(&l.ID, &l.LoanID, &l.Rank, &l.DueDate, &l.TotalAmount, &l.Capital, &l.Interest, &l.RemainingCapital) lines = append(lines, l) } return lines, nil } // ── Handler ─────────────────────────────────────────────────────────────────── type Handler struct{ store *Store } func NewHandler(store *Store) *Handler { return &Handler{store: store} } func (h *Handler) ListLoans(w http.ResponseWriter, r *http.Request) { loans, err := h.store.ListLoans(r.URL.Query().Get("property_id")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if loans == nil { loans = []Loan{} } respond(w, loans) } func (h *Handler) CreateLoan(w http.ResponseWriter, r *http.Request) { var l Loan if err := json.NewDecoder(r.Body).Decode(&l); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if err := h.store.CreateLoan(&l); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) respond(w, l) } func (h *Handler) DeleteLoan(w http.ResponseWriter, r *http.Request) { if err := h.store.DeleteLoan(mux.Vars(r)["id"]); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // UploadLines : reçoit le tableau d'amortissement sous forme JSON func (h *Handler) UploadLines(w http.ResponseWriter, r *http.Request) { loanID := mux.Vars(r)["id"] var lines []LoanLine if err := json.NewDecoder(r.Body).Decode(&lines); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if err := h.store.InsertLines(loanID, lines); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } respond(w, map[string]int{"imported": len(lines)}) } func (h *Handler) GetLines(w http.ResponseWriter, r *http.Request) { loanID := mux.Vars(r)["id"] year := r.URL.Query().Get("year") var lines []LoanLine var err error if year != "" { lines, err = h.store.GetLinesByYear(loanID, year) } else { lines, err = h.store.GetLines(loanID) } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if lines == nil { lines = []LoanLine{} } respond(w, lines) } // SplitByDate : retourne la décomposition capital/intérêts pour une date func (h *Handler) SplitByDate(w http.ResponseWriter, r *http.Request) { loanID := mux.Vars(r)["id"] date := r.URL.Query().Get("date") if date == "" { http.Error(w, "date requise (YYYY-MM-DD)", http.StatusBadRequest) return } line, err := h.store.GetLineByDate(loanID, date) if err == sql.ErrNoRows { http.Error(w, fmt.Sprintf("aucune échéance trouvée pour le %s", date), http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } respond(w, line) } // AnnualSummary : résumé annuel intérêts/capital pour la liasse fiscale func (h *Handler) AnnualSummary(w http.ResponseWriter, r *http.Request) { loanID := mux.Vars(r)["id"] year := r.URL.Query().Get("year") if year == "" { year = strconv.Itoa(2026) } lines, err := h.store.GetLinesByYear(loanID, year) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } var totalCapital, totalInterest, totalPayment float64 for _, l := range lines { totalCapital += l.Capital totalInterest += l.Interest totalPayment += l.TotalAmount } respond(w, map[string]any{ "loan_id": loanID, "year": year, "months": len(lines), "total_payment": totalPayment, "total_capital": totalCapital, "total_interest": totalInterest, }) } // ReloadLines recharge les lignes d'amortissement depuis les données embarquées func (h *Handler) ReloadLines(w http.ResponseWriter, r *http.Request) { loanID := mux.Vars(r)["id"] var ref string if err := h.store.db.QueryRow(`SELECT COALESCE(reference,'') FROM loans WHERE id=?`, loanID).Scan(&ref); err != nil { http.Error(w, "prêt introuvable", http.StatusNotFound) return } var lines []LoanLine switch { case strings.Contains(ref, "781495"): lines = GetLoan781495Lines() case strings.Contains(ref, "781728"): lines = GetLoan781728Lines() default: http.Error(w, "aucune donnée embarquée pour ce prêt", http.StatusNotFound) return } if err := h.store.InsertLines(loanID, lines); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } respond(w, map[string]int{"reloaded": len(lines)}) } func respond(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } // GetSplitForAmount retourne la décomposition capital/intérêts pour un montant et une date // Cherche dans tous les prêts la ligne correspondant à la date func (h *Handler) GetSplitForAmount(w http.ResponseWriter, r *http.Request) { date := r.URL.Query().Get("date") if date == "" { http.Error(w, "date requise", http.StatusBadRequest) return } var count int h.store.db.QueryRow(`SELECT COUNT(*) FROM loan_lines`).Scan(&count) if count == 0 { http.Error(w, "aucune ligne d'amortissement en base — ajoutez les prêts dans la page Prêts", http.StatusNotFound) return } rows, err := h.store.db.Query(` SELECT ll.loan_id, ll.rank, ll.capital, ll.interest, ll.total_amount, l.reference, l.label, l.property_id FROM loan_lines ll JOIN loans l ON l.id = ll.loan_id WHERE strftime('%Y-%m', ll.due_date) = strftime('%Y-%m', ?) AND ll.capital > 0 ORDER BY ll.loan_id`, date) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() type SplitResult struct { LoanID string `json:"loan_id"` LoanRef string `json:"loan_ref"` LoanLabel string `json:"loan_label"` PropertyID string `json:"property_id"` Rank int `json:"rank"` Capital float64 `json:"capital"` Interest float64 `json:"interest"` Total float64 `json:"total"` } var results []SplitResult for rows.Next() { var s SplitResult if err := rows.Scan(&s.LoanID, &s.Rank, &s.Capital, &s.Interest, &s.Total, &s.LoanRef, &s.LoanLabel, &s.PropertyID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } results = append(results, s) } if results == nil { results = []SplitResult{} } respond(w, results) }