417 lines
13 KiB
Go
417 lines
13 KiB
Go
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)
|
|
}
|