This commit is contained in:
2026-04-11 12:12:07 +02:00
parent 3bc6e2e080
commit 5b3c5ebb2f
92 changed files with 10948 additions and 35 deletions

416
internal/loan/loan.go Normal file
View File

@@ -0,0 +1,416 @@
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)
}