up
This commit is contained in:
416
internal/loan/loan.go
Normal file
416
internal/loan/loan.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user