up
This commit is contained in:
316
internal/auth/auth.go
Normal file
316
internal/auth/auth.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userKey contextKey = "user"
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) GetByEmail(email string) (*User, string, error) {
|
||||
var u User
|
||||
var hash string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, email, name, password_hash, created_at FROM users WHERE email=?`, email,
|
||||
).Scan(&u.ID, &u.Email, &u.Name, &hash, &u.CreatedAt)
|
||||
return &u, hash, err
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(id string) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, email, name, created_at FROM users WHERE id=?`, id,
|
||||
).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (s *Store) List() ([]User, error) {
|
||||
rows, err := s.db.Query(`SELECT id, email, name, created_at FROM users ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(email, name, password string) (*User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := &User{ID: uuid.NewString(), Email: email, Name: name}
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO users (id, email, name, password_hash) VALUES (?,?,?,?)`,
|
||||
u.ID, u.Email, u.Name, string(hash),
|
||||
)
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProfile(id, email, name string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET email=?, name=? WHERE id=?`, email, name, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePassword(id, newPassword string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE users SET password_hash=? WHERE id=?`, string(hash), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM users WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Count() int {
|
||||
var n int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) CheckPassword(id, password string) bool {
|
||||
var hash string
|
||||
s.db.QueryRow(`SELECT password_hash FROM users WHERE id=?`, id).Scan(&hash)
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// Sessions en mémoire
|
||||
var sessions = map[string]string{}
|
||||
|
||||
func (s *Store) CreateSession(userID string) string {
|
||||
token := uuid.NewString()
|
||||
sessions[token] = userID
|
||||
return token
|
||||
}
|
||||
|
||||
func (s *Store) GetUserFromToken(token string) (*User, error) {
|
||||
id, ok := sessions[token]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return s.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(token string) {
|
||||
delete(sessions, token)
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler struct{ store *Store }
|
||||
|
||||
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, hash, err := h.store.GetByEmail(body.Email)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) != nil {
|
||||
http.Error(w, "identifiants incorrects", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token := h.store.CreateSession(user.ID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session", Value: token, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"user": user, "token": token})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("session")
|
||||
if err == nil {
|
||||
h.store.DeleteSession(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Context().Value(userKey).(*User)
|
||||
respond(w, user)
|
||||
}
|
||||
|
||||
// Register — public si aucun user, sinon auth requise
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Email == "" || body.Name == "" || body.Password == "" {
|
||||
http.Error(w, "email, nom et mot de passe requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 6 {
|
||||
http.Error(w, "mot de passe trop court (6 caractères minimum)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, err := h.store.Create(body.Email, body.Name, body.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "email déjà utilisé", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
token := h.store.CreateSession(user.ID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session", Value: token, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]any{"user": user, "token": token})
|
||||
}
|
||||
|
||||
// UpdateProfile — modifier nom + email
|
||||
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Context().Value(userKey).(*User)
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Email == "" || body.Name == "" {
|
||||
http.Error(w, "email et nom requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateProfile(user.ID, body.Email, body.Name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user.Email = body.Email
|
||||
user.Name = body.Name
|
||||
respond(w, user)
|
||||
}
|
||||
|
||||
// UpdatePassword — changer son mot de passe
|
||||
func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Context().Value(userKey).(*User)
|
||||
var body struct {
|
||||
Current string `json:"current_password"`
|
||||
New string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !h.store.CheckPassword(user.ID, body.Current) {
|
||||
http.Error(w, "mot de passe actuel incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if len(body.New) < 6 {
|
||||
http.Error(w, "nouveau mot de passe trop court (6 caractères minimum)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdatePassword(user.ID, body.New); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListUsers — liste tous les utilisateurs (admin)
|
||||
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.store.List()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if users == nil {
|
||||
users = []User{}
|
||||
}
|
||||
respond(w, users)
|
||||
}
|
||||
|
||||
// DeleteUser — supprimer un utilisateur (ne peut pas se supprimer soi-même)
|
||||
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
me := r.Context().Value(userKey).(*User)
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == me.ID {
|
||||
http.Error(w, "impossible de supprimer son propre compte", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
|
||||
func Middleware(store *Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := ""
|
||||
if c, err := r.Cookie("session"); err == nil {
|
||||
token = c.Value
|
||||
}
|
||||
if token == "" {
|
||||
token = r.Header.Get("Authorization")
|
||||
}
|
||||
user, err := store.GetUserFromToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "non autorisé", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
207
internal/calendar/calendar.go
Normal file
207
internal/calendar/calendar.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
PropertyID string `json:"property_id"`
|
||||
Title string `json:"title"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Source string `json:"source"` // airbnb | manual
|
||||
IcalUID string `json:"ical_uid,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Joint
|
||||
PropertyName string `json:"property_name,omitempty"`
|
||||
}
|
||||
|
||||
type OccupancyStats struct {
|
||||
PropertyID string `json:"property_id"`
|
||||
PropertyName string `json:"property_name"`
|
||||
Month string `json:"month"`
|
||||
TotalDays int `json:"total_days"`
|
||||
OccupiedDays int `json:"occupied_days"`
|
||||
OccupancyRate float64 `json:"occupancy_rate"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
AvgNightlyRate float64 `json:"avg_nightly_rate"`
|
||||
}
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) List(propertyID, from, to string) ([]Event, error) {
|
||||
query := `
|
||||
SELECT e.id, e.property_id, COALESCE(e.title,''),
|
||||
substr(e.start_date,1,10) as start_date,
|
||||
substr(e.end_date,1,10) as end_date,
|
||||
e.source, COALESCE(e.ical_uid,''), COALESCE(e.notes,''), e.created_at, p.name
|
||||
FROM calendar_events e
|
||||
JOIN properties p ON p.id = e.property_id
|
||||
WHERE 1=1`
|
||||
args := []any{}
|
||||
if propertyID != "" {
|
||||
query += " AND e.property_id=?"
|
||||
args = append(args, propertyID)
|
||||
}
|
||||
if from != "" {
|
||||
query += " AND e.end_date >= ?"
|
||||
args = append(args, from)
|
||||
}
|
||||
if to != "" {
|
||||
query += " AND e.start_date <= ?"
|
||||
args = append(args, to)
|
||||
}
|
||||
query += " ORDER BY e.start_date"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if err := rows.Scan(&e.ID, &e.PropertyID, &e.Title, &e.StartDate, &e.EndDate,
|
||||
&e.Source, &e.IcalUID, &e.Notes, &e.CreatedAt, &e.PropertyName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteIcalEvents(propertyID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE property_id=? AND source='airbnb'`, propertyID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) InsertFromIcal(e *Event) error {
|
||||
e.ID = uuid.NewString()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO calendar_events (id, property_id, title, start_date, end_date, source, ical_uid, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?)`,
|
||||
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, "airbnb", e.IcalUID, e.Notes,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Create(e *Event) error {
|
||||
e.ID = uuid.NewString()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO calendar_events (id, property_id, title, start_date, end_date, source, notes) VALUES (?,?,?,?,?,?,?)`,
|
||||
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, e.Source, e.Notes,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Update(e *Event) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE calendar_events SET title=?, start_date=?, end_date=?, notes=? WHERE id=? AND source='manual'`,
|
||||
e.Title, e.StartDate, e.EndDate, e.Notes, e.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE id=? AND source='manual'`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) LogSync(propertyID, status string, imported int, errMsg string) {
|
||||
s.db.Exec(
|
||||
`INSERT INTO ical_sync_log (id, property_id, status, events_imported, error_message) VALUES (?,?,?,?,?)`,
|
||||
uuid.NewString(), propertyID, status, imported, nullStr(errMsg),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler struct{ store *Store }
|
||||
|
||||
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
events, err := h.store.List(q.Get("property_id"), q.Get("from"), q.Get("to"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []Event{}
|
||||
}
|
||||
respond(w, events)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var e Event
|
||||
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
e.Source = "manual"
|
||||
if err := h.store.Create(&e); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respond(w, e)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var e Event
|
||||
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
e.ID = mux.Vars(r)["id"]
|
||||
if err := h.store.Update(&e); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, e)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: calcul taux d'occupation par mois
|
||||
respond(w, []OccupancyStats{})
|
||||
}
|
||||
|
||||
func (h *Handler) TriggerSync(w http.ResponseWriter, r *http.Request) {
|
||||
// Le service iCal expose un endpoint pour forcer la sync
|
||||
respond(w, map[string]string{"status": "sync triggered"})
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
140
internal/category/category.go
Normal file
140
internal/category/category.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package category
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // income | expense
|
||||
TaxDeductible bool `json:"tax_deductible"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) List(txType string) ([]Category, error) {
|
||||
query := `SELECT id, name, type, tax_deductible, COALESCE(description,'') FROM categories WHERE 1=1`
|
||||
args := []any{}
|
||||
if txType != "" {
|
||||
query += " AND type=?"
|
||||
args = append(args, txType)
|
||||
}
|
||||
query += " ORDER BY type, name"
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var cats []Category
|
||||
for rows.Next() {
|
||||
var c Category
|
||||
var td int
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &td, &c.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.TaxDeductible = td == 1
|
||||
cats = append(cats, c)
|
||||
}
|
||||
return cats, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(c *Category) error {
|
||||
c.ID = uuid.NewString()
|
||||
td := 0
|
||||
if c.TaxDeductible {
|
||||
td = 1
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO categories (id, name, type, tax_deductible, description) VALUES (?,?,?,?,?)`,
|
||||
c.ID, c.Name, c.Type, td, c.Description,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Update(c *Category) error {
|
||||
td := 0
|
||||
if c.TaxDeductible {
|
||||
td = 1
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE categories SET name=?, type=?, tax_deductible=?, description=? WHERE id=?`,
|
||||
c.Name, c.Type, td, c.Description, c.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.db.Exec(`UPDATE transactions SET category_id=NULL WHERE category_id=?`, id)
|
||||
_, err := s.db.Exec(`DELETE FROM categories WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
type Handler struct{ store *Store }
|
||||
|
||||
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
cats, err := h.store.List(r.URL.Query().Get("type"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cats == nil {
|
||||
cats = []Category{}
|
||||
}
|
||||
respond(w, cats)
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var c Category
|
||||
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.Name == "" || (c.Type != "income" && c.Type != "expense") {
|
||||
http.Error(w, "nom et type (income/expense) requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.Create(&c); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respond(w, c)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var c Category
|
||||
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.ID = mux.Vars(r)["id"]
|
||||
if err := h.store.Update(&c); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, c)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
150
internal/db/db.go
Normal file
150
internal/db/db.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func Init(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_foreign_keys=on&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("✓ SQLite connecté : %s", path)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func Migrate(db *sql.DB) error {
|
||||
migrations := []string{
|
||||
sqlCreateUsers,
|
||||
sqlCreateProperties,
|
||||
sqlCreateCalendarEvents,
|
||||
sqlCreateCategories,
|
||||
sqlCreateTransactions,
|
||||
sqlCreateDocuments,
|
||||
sqlCreateFiscalExports,
|
||||
sqlCreateIcalSyncLog,
|
||||
sqlSeedCategories,
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if _, err := db.Exec(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Println("✓ Migrations appliquées")
|
||||
return nil
|
||||
}
|
||||
|
||||
const sqlCreateUsers = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const sqlCreateProperties = `
|
||||
CREATE TABLE IF NOT EXISTS properties (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('airbnb','longterm')),
|
||||
bank_account TEXT,
|
||||
ical_url TEXT,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const sqlCreateCalendarEvents = `
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
source TEXT NOT NULL CHECK(source IN ('airbnb','manual')),
|
||||
ical_uid TEXT,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(property_id, ical_uid)
|
||||
);`
|
||||
|
||||
const sqlCreateCategories = `
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('income','expense')),
|
||||
tax_deductible INTEGER DEFAULT 0,
|
||||
description TEXT
|
||||
);`
|
||||
|
||||
const sqlCreateTransactions = `
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
|
||||
category_id TEXT REFERENCES categories(id),
|
||||
type TEXT NOT NULL CHECK(type IN ('income','expense')),
|
||||
amount REAL NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
description TEXT,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const sqlCreateDocuments = `
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
|
||||
transaction_id TEXT REFERENCES transactions(id) ON DELETE SET NULL,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
fiscal_year INTEGER,
|
||||
uploaded_by TEXT REFERENCES users(id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const sqlCreateFiscalExports = `
|
||||
CREATE TABLE IF NOT EXISTS fiscal_exports (
|
||||
id TEXT PRIMARY KEY,
|
||||
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
|
||||
fiscal_year INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
generated_by TEXT REFERENCES users(id),
|
||||
generated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const sqlCreateIcalSyncLog = `
|
||||
CREATE TABLE IF NOT EXISTS ical_sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
|
||||
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT NOT NULL CHECK(status IN ('ok','error')),
|
||||
events_imported INTEGER DEFAULT 0,
|
||||
error_message TEXT
|
||||
);`
|
||||
|
||||
// Catégories de base prêtes à l'emploi
|
||||
const sqlSeedCategories = `
|
||||
INSERT OR IGNORE INTO categories (id, name, type, tax_deductible) VALUES
|
||||
('cat-loyer', 'Loyer perçu', 'income', 0),
|
||||
('cat-airbnb', 'Revenu Airbnb', 'income', 0),
|
||||
('cat-charges', 'Charges copropriété', 'expense', 1),
|
||||
('cat-travaux', 'Travaux & réparations', 'expense', 1),
|
||||
('cat-assurance', 'Assurance', 'expense', 1),
|
||||
('cat-taxe', 'Taxe foncière', 'expense', 1),
|
||||
('cat-interets', 'Intérêts emprunt', 'expense', 1),
|
||||
('cat-menage', 'Ménage & entretien', 'expense', 1),
|
||||
('cat-gestion', 'Frais de gestion', 'expense', 1),
|
||||
('cat-electricite', 'Électricité', 'expense', 1),
|
||||
('cat-eau', 'Eau', 'expense', 1),
|
||||
('cat-internet', 'Internet', 'expense', 1),
|
||||
('cat-autre-dep', 'Autre dépense', 'expense', 0),
|
||||
('cat-autre-rev', 'Autre revenu', 'income', 0);`
|
||||
330
internal/document/document.go
Normal file
330
internal/document/document.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
PropertyID string `json:"property_id"`
|
||||
TransactionID string `json:"transaction_id,omitempty"`
|
||||
Filename string `json:"filename"`
|
||||
OriginalName string `json:"original_name"`
|
||||
FilePath string `json:"-"`
|
||||
MimeType string `json:"mime_type"`
|
||||
FiscalYear int `json:"fiscal_year"`
|
||||
Category string `json:"category"`
|
||||
UploadedBy string `json:"uploaded_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Joint
|
||||
PropertyName string `json:"property_name,omitempty"`
|
||||
}
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// Migrate ajoute la colonne category si elle n'existe pas encore.
|
||||
func (s *Store) Migrate() {
|
||||
s.db.Exec(`ALTER TABLE documents ADD COLUMN doc_month INTEGER NOT NULL DEFAULT 0`)
|
||||
s.db.Exec(`ALTER TABLE documents ADD COLUMN category TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
|
||||
func (s *Store) List(propertyID string, fiscalYear int, category string) ([]Document, error) {
|
||||
query := `
|
||||
SELECT d.id, d.property_id, COALESCE(d.transaction_id,''), d.filename, d.original_name,
|
||||
d.file_path, COALESCE(d.mime_type,''), COALESCE(d.fiscal_year,0),
|
||||
COALESCE(d.category,''),
|
||||
COALESCE(d.uploaded_by,''), d.created_at, p.name
|
||||
FROM documents d
|
||||
JOIN properties p ON p.id = d.property_id
|
||||
WHERE 1=1`
|
||||
args := []any{}
|
||||
if propertyID != "" {
|
||||
query += " AND d.property_id=?"
|
||||
args = append(args, propertyID)
|
||||
}
|
||||
if fiscalYear > 0 {
|
||||
query += " AND d.fiscal_year=?"
|
||||
args = append(args, fiscalYear)
|
||||
}
|
||||
if category != "" {
|
||||
query += " AND d.category=?"
|
||||
args = append(args, category)
|
||||
}
|
||||
query += " ORDER BY d.fiscal_year DESC, d.created_at DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var docs []Document
|
||||
for rows.Next() {
|
||||
var d Document
|
||||
if err := rows.Scan(&d.ID, &d.PropertyID, &d.TransactionID, &d.Filename, &d.OriginalName,
|
||||
&d.FilePath, &d.MimeType, &d.FiscalYear, &d.Category,
|
||||
&d.UploadedBy, &d.CreatedAt, &d.PropertyName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs = append(docs, d)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Document, error) {
|
||||
var d Document
|
||||
err := s.db.QueryRow(`
|
||||
SELECT d.id, d.property_id, COALESCE(d.transaction_id,''), d.filename, d.original_name,
|
||||
d.file_path, COALESCE(d.mime_type,''), COALESCE(d.fiscal_year,0),
|
||||
COALESCE(d.category,''),
|
||||
COALESCE(d.uploaded_by,''), d.created_at, p.name
|
||||
FROM documents d
|
||||
JOIN properties p ON p.id = d.property_id
|
||||
WHERE d.id=?`, id,
|
||||
).Scan(&d.ID, &d.PropertyID, &d.TransactionID, &d.Filename, &d.OriginalName,
|
||||
&d.FilePath, &d.MimeType, &d.FiscalYear, &d.Category,
|
||||
&d.UploadedBy, &d.CreatedAt, &d.PropertyName)
|
||||
return &d, err
|
||||
}
|
||||
|
||||
func (s *Store) Create(d *Document) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO documents (id, property_id, transaction_id, filename, original_name, file_path, mime_type, fiscal_year, category, uploaded_by) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
d.ID, d.PropertyID, nullStr(d.TransactionID), d.Filename, d.OriginalName, d.FilePath,
|
||||
d.MimeType, nullInt(d.FiscalYear), nullStr(d.Category), nullStr(d.UploadedBy),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) (string, error) {
|
||||
var path string
|
||||
s.db.QueryRow(`SELECT file_path FROM documents WHERE id=?`, id).Scan(&path)
|
||||
_, err := s.db.Exec(`DELETE FROM documents WHERE id=?`, id)
|
||||
return path, err
|
||||
}
|
||||
|
||||
func (s *Store) GetByPropertyAndYear(propertyID string, year int) ([]Document, error) {
|
||||
return s.List(propertyID, year, "")
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, dataDir string) *Handler {
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
return &Handler{store: store, dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
var year int
|
||||
fmt.Sscanf(q.Get("fiscal_year"), "%d", &year)
|
||||
docs, err := h.store.List(q.Get("property_id"), year, q.Get("category"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if docs == nil {
|
||||
docs = []Document{}
|
||||
}
|
||||
respond(w, docs)
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := h.store.Get(mux.Vars(r)["id"])
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, d)
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseMultipartForm(32 << 20)
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "fichier requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
id := uuid.NewString()
|
||||
ext := filepath.Ext(header.Filename)
|
||||
filename := id + ext
|
||||
|
||||
propertyID := r.FormValue("property_id")
|
||||
year := r.FormValue("fiscal_year")
|
||||
dir := filepath.Join(h.dataDir, propertyID, year)
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
destPath := filepath.Join(dir, filename)
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
http.Error(w, "erreur création fichier", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dest.Close()
|
||||
io.Copy(dest, file)
|
||||
|
||||
var fiscalYear int
|
||||
fmt.Sscanf(year, "%d", &fiscalYear)
|
||||
|
||||
d := &Document{
|
||||
ID: id,
|
||||
PropertyID: propertyID,
|
||||
TransactionID: r.FormValue("transaction_id"),
|
||||
Filename: filename,
|
||||
OriginalName: header.Filename,
|
||||
FilePath: destPath,
|
||||
MimeType: header.Header.Get("Content-Type"),
|
||||
FiscalYear: fiscalYear,
|
||||
Category: r.FormValue("category"),
|
||||
UploadedBy: r.FormValue("uploaded_by"),
|
||||
}
|
||||
|
||||
if err := h.store.Create(d); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respond(w, d)
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := h.store.Get(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, d.OriginalName))
|
||||
if d.MimeType != "" {
|
||||
w.Header().Set("Content-Type", d.MimeType)
|
||||
}
|
||||
http.ServeFile(w, r, d.FilePath)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.store.Delete(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if path != "" {
|
||||
os.Remove(path)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Export génère un ZIP avec la structure : année/catégorie/fichier
|
||||
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
propertyID := q.Get("property_id")
|
||||
yearStr := q.Get("year")
|
||||
var year int
|
||||
fmt.Sscanf(yearStr, "%d", &year)
|
||||
if year == 0 {
|
||||
year = time.Now().Year()
|
||||
yearStr = fmt.Sprintf("%d", year)
|
||||
}
|
||||
|
||||
docs, err := h.store.List(propertyID, year, "")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
http.Error(w, "aucun document pour ces critères", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
propLabel := "tous"
|
||||
if propertyID != "" {
|
||||
if docs[0].PropertyName != "" {
|
||||
propLabel = sanitizeName(docs[0].PropertyName)
|
||||
} else {
|
||||
propLabel = propertyID[:8]
|
||||
}
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("documents_%s_%s.zip", propLabel, yearStr)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
for _, d := range docs {
|
||||
catDir := "Sans catégorie"
|
||||
if strings.TrimSpace(d.Category) != "" {
|
||||
catDir = sanitizeName(d.Category)
|
||||
}
|
||||
|
||||
zipPath := filepath.Join(yearStr, catDir, d.OriginalName)
|
||||
zipPath = strings.ReplaceAll(zipPath, "\\", "/")
|
||||
|
||||
f, err := os.Open(d.FilePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entry, err := zw.Create(zipPath)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
io.Copy(entry, f)
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeName(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "-", "\\", "-", ":", "-", "*", "-",
|
||||
"?", "-", "\"", "-", "<", "-", ">", "-", "|", "-",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(s))
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" || strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullInt(i int) any {
|
||||
if i == 0 {
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
91
internal/fiscal/fiscal.go
Normal file
91
internal/fiscal/fiscal.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package fiscal
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/f4bpo/rental-manager/internal/document"
|
||||
"github.com/f4bpo/rental-manager/internal/transaction"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
txStore *transaction.Store
|
||||
docStore *document.Store
|
||||
}
|
||||
|
||||
func NewHandler(txStore *transaction.Store, docStore *document.Store) *Handler {
|
||||
return &Handler{txStore: txStore, docStore: docStore}
|
||||
}
|
||||
|
||||
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
year := q.Get("year")
|
||||
if year == "" {
|
||||
year = strconv.Itoa(time.Now().Year())
|
||||
}
|
||||
summaries, err := h.txStore.GetSummary(q.Get("property_id"), year, "")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if summaries == nil {
|
||||
summaries = []transaction.Summary{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(summaries)
|
||||
}
|
||||
|
||||
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
propertyID := q.Get("property_id")
|
||||
yearStr := q.Get("year")
|
||||
if yearStr == "" {
|
||||
yearStr = strconv.Itoa(time.Now().Year())
|
||||
}
|
||||
|
||||
// List() gère correctement propertyID vide et year en string
|
||||
txs, err := h.txStore.List(propertyID, "", yearStr, "")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
propLabel := propertyID
|
||||
if propLabel == "" {
|
||||
propLabel = "tous"
|
||||
}
|
||||
filename := fmt.Sprintf("export_fiscal_%s_%s.csv", propLabel, yearStr)
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
|
||||
w.Write([]byte{0xEF, 0xBB, 0xBF}) // BOM UTF-8 pour Excel
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
cw.Comma = ';'
|
||||
cw.Write([]string{"Date", "Type", "Catégorie", "Description", "Montant (€)", "Bien"})
|
||||
|
||||
var totalIncome, totalExpense float64
|
||||
for _, t := range txs {
|
||||
typeLabel := "Revenu"
|
||||
if t.Type == "expense" {
|
||||
typeLabel = "Dépense"
|
||||
totalExpense += t.Amount
|
||||
} else {
|
||||
totalIncome += t.Amount
|
||||
}
|
||||
cw.Write([]string{
|
||||
t.Date, typeLabel, t.CategoryName,
|
||||
t.Description, fmt.Sprintf("%.2f", t.Amount), t.PropertyName,
|
||||
})
|
||||
}
|
||||
|
||||
cw.Write([]string{})
|
||||
cw.Write([]string{"", "", "", "TOTAL REVENUS", fmt.Sprintf("%.2f", totalIncome), ""})
|
||||
cw.Write([]string{"", "", "", "TOTAL DÉPENSES", fmt.Sprintf("%.2f", totalExpense), ""})
|
||||
cw.Write([]string{"", "", "", "BÉNÉFICE NET", fmt.Sprintf("%.2f", totalIncome-totalExpense), ""})
|
||||
cw.Flush()
|
||||
}
|
||||
204
internal/ical/ical.go
Normal file
204
internal/ical/ical.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package ical
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/f4bpo/rental-manager/internal/calendar"
|
||||
"github.com/f4bpo/rental-manager/internal/property"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
calStore *calendar.Store
|
||||
propStore *property.Store
|
||||
}
|
||||
|
||||
func NewService(calStore *calendar.Store, propStore *property.Store) *Service {
|
||||
return &Service{calStore: calStore, propStore: propStore}
|
||||
}
|
||||
|
||||
// StartSync lance une goroutine qui synchronise toutes les heures
|
||||
func (s *Service) StartSync() {
|
||||
go func() {
|
||||
s.syncAll()
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
for range ticker.C {
|
||||
s.syncAll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) SyncProperty(prop *property.Property) (int, error) {
|
||||
if prop.IcalURL == "" {
|
||||
return 0, fmt.Errorf("pas d'URL iCal pour %s", prop.Name)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", prop.IcalURL, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur création requête iCal: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; RentalManager/1.0)")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur fetch iCal: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("iCal HTTP %d pour %s", resp.StatusCode, prop.IcalURL)
|
||||
}
|
||||
|
||||
events, err := parseIcal(resp, prop.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Supprimer les anciens événements iCal pour repartir d'une base propre
|
||||
// (gère aussi les réservations annulées et les doublons)
|
||||
if err := s.calStore.DeleteIcalEvents(prop.ID); err != nil {
|
||||
return 0, fmt.Errorf("erreur nettoyage calendrier: %w", err)
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, e := range events {
|
||||
if err := s.calStore.InsertFromIcal(&e); err != nil {
|
||||
log.Printf("ical: erreur insert event %s: %v", e.IcalUID, err)
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
Property string `json:"property"`
|
||||
Imported int `json:"imported"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) SyncAll() []SyncResult {
|
||||
props, err := s.propStore.ListWithIcal()
|
||||
if err != nil {
|
||||
log.Printf("ical sync: erreur liste propriétés: %v", err)
|
||||
return []SyncResult{{Property: "system", Error: err.Error()}}
|
||||
}
|
||||
log.Printf("ical sync: %d propriété(s) avec URL iCal trouvée(s)", len(props))
|
||||
if len(props) == 0 {
|
||||
return []SyncResult{{Property: "system", Error: "aucune propriété avec URL iCal configurée"}}
|
||||
}
|
||||
var results []SyncResult
|
||||
for _, p := range props {
|
||||
n, err := s.SyncProperty(&p)
|
||||
if err != nil {
|
||||
log.Printf("ical sync [%s]: erreur: %v", p.Name, err)
|
||||
s.calStore.LogSync(p.ID, "error", 0, err.Error())
|
||||
results = append(results, SyncResult{Property: p.Name, Error: err.Error()})
|
||||
} else {
|
||||
log.Printf("ical sync [%s]: %d événements importés", p.Name, n)
|
||||
s.calStore.LogSync(p.ID, "ok", n, "")
|
||||
results = append(results, SyncResult{Property: p.Name, Imported: n})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) syncAll() { s.SyncAll() }
|
||||
|
||||
// parseIcal parse un flux iCal et retourne les événements VEVENT
|
||||
func parseIcal(resp *http.Response, propertyID string) ([]calendar.Event, error) {
|
||||
// Unfold iCal lines (RFC 5545: continuation lines start with space or tab)
|
||||
lines := unfoldIcal(resp)
|
||||
|
||||
var events []calendar.Event
|
||||
var current *calendar.Event
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case line == "BEGIN:VEVENT":
|
||||
current = &calendar.Event{PropertyID: propertyID, Source: "airbnb"}
|
||||
|
||||
case line == "END:VEVENT" && current != nil:
|
||||
if current.IcalUID != "" && current.StartDate != "" && current.EndDate != "" {
|
||||
events = append(events, *current)
|
||||
} else {
|
||||
log.Printf("ical: VEVENT ignoré (uid=%q start=%q end=%q)", current.IcalUID, current.StartDate, current.EndDate)
|
||||
}
|
||||
current = nil
|
||||
|
||||
case current != nil:
|
||||
key, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
// Gérer les paramètres du type DTSTART;VALUE=DATE:20240601
|
||||
baseKey := strings.SplitN(key, ";", 2)[0]
|
||||
switch baseKey {
|
||||
case "UID":
|
||||
current.IcalUID = value
|
||||
case "SUMMARY":
|
||||
current.Title = unescapeIcal(value)
|
||||
case "DTSTART":
|
||||
current.StartDate = parseIcalDate(value)
|
||||
case "DTEND":
|
||||
current.EndDate = parseIcalDate(value)
|
||||
case "DESCRIPTION":
|
||||
current.Notes = unescapeIcal(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("ical: %d événements parsés pour la propriété %s", len(events), propertyID)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// unfoldIcal joint les lignes repliées (RFC 5545 §3.1)
|
||||
func unfoldIcal(resp *http.Response) []string {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer
|
||||
var lines []string
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
if len(lines) > 0 && len(text) > 0 && (text[0] == ' ' || text[0] == '\t') {
|
||||
// Continuation line: append to previous
|
||||
lines[len(lines)-1] += strings.TrimLeft(text, " \t")
|
||||
} else {
|
||||
lines = append(lines, text)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func parseIcalDate(s string) string {
|
||||
// Extraire uniquement la partie date (avant tout T, +, -)
|
||||
// pour gérer : YYYYMMDD, YYYYMMDDTHHMMSSZ, YYYYMMDDTHHMMSS, YYYYMMDDTHHMMSS+HHMM, etc.
|
||||
raw := strings.SplitN(s, "T", 2)[0] // prend "YYYYMMDD" avant le T éventuel
|
||||
raw = strings.SplitN(raw, "+", 2)[0]
|
||||
raw = strings.SplitN(raw, "Z", 2)[0]
|
||||
|
||||
if len(raw) == 8 {
|
||||
return fmt.Sprintf("%s-%s-%s", raw[0:4], raw[4:6], raw[6:8])
|
||||
}
|
||||
// Déjà au format YYYY-MM-DD
|
||||
if len(s) >= 10 && s[4] == '-' {
|
||||
return s[:10]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func unescapeIcal(s string) string {
|
||||
s = strings.ReplaceAll(s, `\n`, "\n")
|
||||
s = strings.ReplaceAll(s, `\,`, ",")
|
||||
s = strings.ReplaceAll(s, `\;`, ";")
|
||||
s = strings.ReplaceAll(s, `\\`, `\`)
|
||||
return s
|
||||
}
|
||||
307
internal/importer/qif.go
Normal file
307
internal/importer/qif.go
Normal file
@@ -0,0 +1,307 @@
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
269
internal/loan/pdf_parser.go
Normal file
269
internal/loan/pdf_parser.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package loan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// UploadPDF reçoit un PDF, tente de le parser via Python si disponible,
|
||||
// sinon crée le prêt avec saisie manuelle
|
||||
func (h *Handler) UploadPDF(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseMultipartForm(20 << 20)
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "fichier PDF requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
propertyID := r.FormValue("property_id")
|
||||
if propertyID == "" {
|
||||
http.Error(w, "property_id requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
labelInput := r.FormValue("label")
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(file)
|
||||
pdfBytes := buf.Bytes()
|
||||
|
||||
// Tenter l'extraction Python (optionnel)
|
||||
ref, initialAmount, monthly := extractInfoFallback(pdfBytes)
|
||||
|
||||
label := labelInput
|
||||
if label == "" {
|
||||
if ref != "" {
|
||||
label = fmt.Sprintf("Prêt %s", ref)
|
||||
} else {
|
||||
label = "Prêt immobilier"
|
||||
}
|
||||
}
|
||||
|
||||
loan := &Loan{
|
||||
PropertyID: propertyID,
|
||||
Label: label,
|
||||
Reference: ref,
|
||||
InitialAmount: initialAmount,
|
||||
MonthlyPayment: monthly,
|
||||
}
|
||||
if err := h.store.CreateLoan(loan); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Tenter le parsing des lignes via Python
|
||||
lines, parseErr := parseLinesWithPython(pdfBytes)
|
||||
linesImported := 0
|
||||
|
||||
if parseErr == nil && len(lines) > 0 {
|
||||
h.store.InsertLines(loan.ID, lines)
|
||||
linesImported = len(lines)
|
||||
} else {
|
||||
// Pas de Python : essayer les données embarquées selon la référence
|
||||
switch {
|
||||
case strings.Contains(ref, "781495"):
|
||||
lines = GetLoan781495Lines()
|
||||
case strings.Contains(ref, "781728"):
|
||||
lines = GetLoan781728Lines()
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
h.store.InsertLines(loan.ID, lines)
|
||||
linesImported = len(lines)
|
||||
}
|
||||
}
|
||||
|
||||
respond(w, map[string]any{
|
||||
"loan": loan,
|
||||
"lines_imported": linesImported,
|
||||
"python_used": parseErr == nil,
|
||||
})
|
||||
}
|
||||
|
||||
// extractInfoFallback tente d'extraire les infos via Python, retourne des zéros si indisponible
|
||||
func extractInfoFallback(pdfBytes []byte) (ref string, initialAmount, monthly float64) {
|
||||
script := `
|
||||
import sys, json, pdfplumber, io, re
|
||||
data = sys.stdin.buffer.read()
|
||||
result = {'reference': '', 'initial_amount': 0, 'monthly': 0}
|
||||
with pdfplumber.open(io.BytesIO(data)) as pdf:
|
||||
text = pdf.pages[0].extract_text() or ''
|
||||
m = re.search(r'cr.dit\s*:\s*([\w]+)', text)
|
||||
if m: result['reference'] = m.group(1).strip()
|
||||
m = re.search(r'Montant du pr.t\s*:\s*([\d\s,\.]+)\s*EUR', text)
|
||||
if m:
|
||||
try: result['initial_amount'] = float(m.group(1).strip().replace(' ','').replace('\u202f','').replace(',','.'))
|
||||
except: pass
|
||||
for page in pdf.pages:
|
||||
for table in (page.extract_tables() or []):
|
||||
if not table: continue
|
||||
for row in table[1:]:
|
||||
if not row or not row[2]: continue
|
||||
vals=[v.strip() for v in str(row[2]).split('\n') if v.strip()]
|
||||
if len(vals)>=3 and len(set(vals[:3]))==1:
|
||||
try:
|
||||
result['monthly']=float(vals[0].replace(' ','').replace('\u202f','').replace(',','.'))
|
||||
break
|
||||
except: pass
|
||||
if result['monthly']: break
|
||||
if result['monthly']: break
|
||||
print(json.dumps(result))
|
||||
`
|
||||
py := pythonBin()
|
||||
if py == "" {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(py, "-c", script)
|
||||
cmd.Stdin = bytes.NewReader(pdfBytes)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if cmd.Run() != nil {
|
||||
return
|
||||
}
|
||||
var info struct {
|
||||
Reference string `json:"reference"`
|
||||
InitialAmount float64 `json:"initial_amount"`
|
||||
Monthly float64 `json:"monthly"`
|
||||
}
|
||||
if json.Unmarshal(out.Bytes(), &info) != nil {
|
||||
return
|
||||
}
|
||||
r := info.Reference
|
||||
if idx := strings.Index(r, "/"); idx > 0 {
|
||||
r = strings.TrimSpace(r[:idx])
|
||||
}
|
||||
return r, info.InitialAmount, info.Monthly
|
||||
}
|
||||
|
||||
// parseLinesWithPython tente le parsing complet via pdfplumber
|
||||
func parseLinesWithPython(pdfBytes []byte) ([]LoanLine, error) {
|
||||
py := pythonBin()
|
||||
if py == "" {
|
||||
return nil, fmt.Errorf("python non disponible")
|
||||
}
|
||||
script := `
|
||||
import sys,json,pdfplumber,io
|
||||
def pa(s):
|
||||
if not s or not s.strip(): return 0.0
|
||||
try: return float(s.strip().replace(' ','').replace('\u202f','').replace('\xa0','').replace(',','.'))
|
||||
except: return 0.0
|
||||
def pd(s):
|
||||
s=s.strip()
|
||||
if '/' in s:
|
||||
p=s.split('/')
|
||||
if len(p)==3: return f"{p[2]}-{p[1]}-{p[0]}"
|
||||
return s
|
||||
lines=[]
|
||||
with pdfplumber.open(io.BytesIO(sys.stdin.buffer.read())) as pdf:
|
||||
for page in pdf.pages:
|
||||
for table in (page.extract_tables() or []):
|
||||
if not table or len(table)<2: continue
|
||||
if not table[0] or 'RANG' not in str(table[0][0]): continue
|
||||
for row in table[1:]:
|
||||
if not row or not row[0]: continue
|
||||
ranks=str(row[0]).split('\n'); dates=str(row[1]).split('\n') if row[1] else []
|
||||
tots=str(row[2]).split('\n') if row[2] else []; caps=str(row[3]).split('\n') if row[3] else []
|
||||
ints=str(row[4]).split('\n') if row[4] else []; rems=str(row[5]).split('\n') if row[5] else []
|
||||
for i,rs in enumerate(ranks):
|
||||
rs=rs.strip()
|
||||
if not rs or not rs.isdigit(): continue
|
||||
c=pa(caps[i] if i<len(caps) else '0')
|
||||
if c==0: continue
|
||||
lines.append({'rank':int(rs),'due_date':pd(dates[i].strip() if i<len(dates) else ''),
|
||||
'total_amount':pa(tots[i] if i<len(tots) else '0'),'capital':c,
|
||||
'interest':pa(ints[i] if i<len(ints) else '0'),
|
||||
'remaining_capital':pa(rems[i] if i<len(rems) else '0')})
|
||||
print(json.dumps(lines))
|
||||
`
|
||||
cmd := exec.Command(py, "-c", script)
|
||||
cmd.Stdin = bytes.NewReader(pdfBytes)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
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"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := make([]LoanLine, len(raw))
|
||||
for i, r := range raw {
|
||||
lines[i] = LoanLine{Rank: r.Rank, DueDate: r.DueDate, TotalAmount: r.TotalAmount,
|
||||
Capital: r.Capital, Interest: r.Interest, RemainingCapital: r.RemainingCapital}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func pythonBin() string {
|
||||
for _, name := range []string{"python3", "python"} {
|
||||
if _, err := exec.LookPath(name); err == nil {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func respondPDF(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// CreateLoanManual crée un prêt depuis un formulaire JSON
|
||||
func (h *Handler) CreateLoanManual(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
PropertyID string `json:"property_id"`
|
||||
Label string `json:"label"`
|
||||
Reference string `json:"reference"`
|
||||
InitialAmount float64 `json:"initial_amount"`
|
||||
MonthlyPayment float64 `json:"monthly_payment"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
loan := &Loan{
|
||||
PropertyID: payload.PropertyID,
|
||||
Label: payload.Label,
|
||||
Reference: payload.Reference,
|
||||
InitialAmount: payload.InitialAmount,
|
||||
MonthlyPayment: payload.MonthlyPayment,
|
||||
}
|
||||
if err := h.store.CreateLoan(loan); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Charger les données embarquées selon la référence
|
||||
var lines []LoanLine
|
||||
switch {
|
||||
case strings.Contains(payload.Reference, "781495"):
|
||||
lines = GetLoan781495Lines()
|
||||
case strings.Contains(payload.Reference, "781728"):
|
||||
lines = GetLoan781728Lines()
|
||||
}
|
||||
linesImported := 0
|
||||
if len(lines) > 0 {
|
||||
h.store.InsertLines(loan.ID, lines)
|
||||
linesImported = len(lines)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"loan": loan,
|
||||
"lines_imported": linesImported,
|
||||
})
|
||||
}
|
||||
|
||||
var _ = mux.Vars // éviter unused import
|
||||
474
internal/loan/seed.go
Normal file
474
internal/loan/seed.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package loan
|
||||
|
||||
// Données extraites des tableaux d'amortissement Caisse d'Epargne CEPAC
|
||||
// Prêt 781495E : 183 765€ — mensualité 1 084,75€ — taux 3,7%
|
||||
// Prêt 781728E : 122 946€ — mensualité 725,74€ — taux 3,7%
|
||||
// Phase amortissement uniquement (rang 8 → rang 247)
|
||||
|
||||
// Prêt 781495E : 216 lignes
|
||||
func GetLoan781495Lines() []LoanLine {
|
||||
return []LoanLine{
|
||||
{Rank: 8, DueDate: "2024-04-05", TotalAmount: 1084.75, Capital: 518.14, Interest: 566.61, RemainingCapital: 183246.86},
|
||||
{Rank: 9, DueDate: "2024-05-05", TotalAmount: 1084.75, Capital: 519.74, Interest: 565.01, RemainingCapital: 182727.12},
|
||||
{Rank: 10, DueDate: "2024-06-05", TotalAmount: 1084.75, Capital: 521.34, Interest: 563.41, RemainingCapital: 182205.78},
|
||||
{Rank: 11, DueDate: "2024-07-05", TotalAmount: 1084.75, Capital: 522.95, Interest: 561.8, RemainingCapital: 181682.83},
|
||||
{Rank: 12, DueDate: "2024-08-05", TotalAmount: 1084.75, Capital: 524.56, Interest: 560.19, RemainingCapital: 181158.27},
|
||||
{Rank: 13, DueDate: "2024-09-05", TotalAmount: 1084.75, Capital: 526.18, Interest: 558.57, RemainingCapital: 180632.09},
|
||||
{Rank: 14, DueDate: "2024-10-05", TotalAmount: 1084.75, Capital: 527.8, Interest: 556.95, RemainingCapital: 180104.29},
|
||||
{Rank: 15, DueDate: "2024-11-05", TotalAmount: 1084.75, Capital: 529.43, Interest: 555.32, RemainingCapital: 179574.86},
|
||||
{Rank: 16, DueDate: "2024-12-05", TotalAmount: 1084.75, Capital: 531.06, Interest: 553.69, RemainingCapital: 179043.8},
|
||||
{Rank: 17, DueDate: "2025-01-05", TotalAmount: 1084.75, Capital: 532.7, Interest: 552.05, RemainingCapital: 178511.1},
|
||||
{Rank: 18, DueDate: "2025-02-05", TotalAmount: 1084.75, Capital: 534.34, Interest: 550.41, RemainingCapital: 177976.76},
|
||||
{Rank: 19, DueDate: "2025-03-05", TotalAmount: 1084.75, Capital: 535.99, Interest: 548.76, RemainingCapital: 177440.77},
|
||||
{Rank: 20, DueDate: "2025-04-05", TotalAmount: 1084.75, Capital: 537.64, Interest: 547.11, RemainingCapital: 176903.13},
|
||||
{Rank: 21, DueDate: "2025-05-05", TotalAmount: 1084.75, Capital: 539.3, Interest: 545.45, RemainingCapital: 176363.83},
|
||||
{Rank: 22, DueDate: "2025-06-05", TotalAmount: 1084.75, Capital: 540.96, Interest: 543.79, RemainingCapital: 175822.87},
|
||||
{Rank: 23, DueDate: "2025-07-05", TotalAmount: 1084.75, Capital: 542.63, Interest: 542.12, RemainingCapital: 175280.24},
|
||||
{Rank: 24, DueDate: "2025-08-05", TotalAmount: 1084.75, Capital: 544.3, Interest: 540.45, RemainingCapital: 174735.94},
|
||||
{Rank: 25, DueDate: "2025-09-05", TotalAmount: 1084.75, Capital: 545.98, Interest: 538.77, RemainingCapital: 174189.96},
|
||||
{Rank: 26, DueDate: "2025-10-05", TotalAmount: 1084.75, Capital: 547.66, Interest: 537.09, RemainingCapital: 173642.3},
|
||||
{Rank: 27, DueDate: "2025-11-05", TotalAmount: 1084.75, Capital: 549.35, Interest: 535.4, RemainingCapital: 173092.95},
|
||||
{Rank: 28, DueDate: "2025-12-05", TotalAmount: 1084.75, Capital: 551.05, Interest: 533.7, RemainingCapital: 172541.9},
|
||||
{Rank: 29, DueDate: "2026-01-05", TotalAmount: 1084.75, Capital: 552.75, Interest: 532.00, RemainingCapital: 171989.15},
|
||||
{Rank: 30, DueDate: "2026-02-05", TotalAmount: 1084.75, Capital: 554.45, Interest: 530.30, RemainingCapital: 171434.70},
|
||||
{Rank: 31, DueDate: "2026-03-05", TotalAmount: 1084.75, Capital: 556.16, Interest: 528.59, RemainingCapital: 170878.54},
|
||||
{Rank: 32, DueDate: "2026-04-05", TotalAmount: 1084.75, Capital: 557.87, Interest: 526.88, RemainingCapital: 170320.67},
|
||||
{Rank: 33, DueDate: "2026-05-05", TotalAmount: 1084.75, Capital: 559.59, Interest: 525.16, RemainingCapital: 169761.08},
|
||||
{Rank: 34, DueDate: "2026-06-05", TotalAmount: 1084.75, Capital: 561.32, Interest: 523.43, RemainingCapital: 169199.76},
|
||||
{Rank: 35, DueDate: "2026-07-05", TotalAmount: 1084.75, Capital: 563.05, Interest: 521.70, RemainingCapital: 168636.71},
|
||||
{Rank: 36, DueDate: "2026-08-05", TotalAmount: 1084.75, Capital: 564.79, Interest: 519.96, RemainingCapital: 168071.92},
|
||||
{Rank: 37, DueDate: "2026-09-05", TotalAmount: 1084.75, Capital: 566.53, Interest: 518.22, RemainingCapital: 167505.39},
|
||||
{Rank: 38, DueDate: "2026-10-05", TotalAmount: 1084.75, Capital: 568.28, Interest: 516.47, RemainingCapital: 166937.11},
|
||||
{Rank: 39, DueDate: "2026-11-05", TotalAmount: 1084.75, Capital: 570.03, Interest: 514.72, RemainingCapital: 166367.08},
|
||||
{Rank: 40, DueDate: "2026-12-05", TotalAmount: 1084.75, Capital: 571.78, Interest: 512.97, RemainingCapital: 165795.30},
|
||||
{Rank: 41, DueDate: "2027-01-05", TotalAmount: 1084.75, Capital: 573.55, Interest: 511.2, RemainingCapital: 165221.75},
|
||||
{Rank: 42, DueDate: "2027-02-05", TotalAmount: 1084.75, Capital: 575.32, Interest: 509.43, RemainingCapital: 164646.43},
|
||||
{Rank: 43, DueDate: "2027-03-05", TotalAmount: 1084.75, Capital: 577.09, Interest: 507.66, RemainingCapital: 164069.34},
|
||||
{Rank: 44, DueDate: "2027-04-05", TotalAmount: 1084.75, Capital: 578.87, Interest: 505.88, RemainingCapital: 163490.47},
|
||||
{Rank: 45, DueDate: "2027-05-05", TotalAmount: 1084.75, Capital: 580.65, Interest: 504.1, RemainingCapital: 162909.82},
|
||||
{Rank: 46, DueDate: "2027-06-05", TotalAmount: 1084.75, Capital: 582.44, Interest: 502.31, RemainingCapital: 162327.38},
|
||||
{Rank: 47, DueDate: "2027-07-05", TotalAmount: 1084.75, Capital: 584.24, Interest: 500.51, RemainingCapital: 161743.14},
|
||||
{Rank: 48, DueDate: "2027-08-05", TotalAmount: 1084.75, Capital: 586.04, Interest: 498.71, RemainingCapital: 161157.1},
|
||||
{Rank: 49, DueDate: "2027-09-05", TotalAmount: 1084.75, Capital: 587.85, Interest: 496.9, RemainingCapital: 160569.25},
|
||||
{Rank: 50, DueDate: "2027-10-05", TotalAmount: 1084.75, Capital: 589.66, Interest: 495.09, RemainingCapital: 159979.59},
|
||||
{Rank: 51, DueDate: "2027-11-05", TotalAmount: 1084.75, Capital: 591.48, Interest: 493.27, RemainingCapital: 159388.11},
|
||||
{Rank: 52, DueDate: "2027-12-05", TotalAmount: 1084.75, Capital: 593.3, Interest: 491.45, RemainingCapital: 158794.81},
|
||||
{Rank: 53, DueDate: "2028-01-05", TotalAmount: 1084.75, Capital: 595.13, Interest: 489.62, RemainingCapital: 158199.68},
|
||||
{Rank: 54, DueDate: "2028-02-05", TotalAmount: 1084.75, Capital: 596.97, Interest: 487.78, RemainingCapital: 157602.71},
|
||||
{Rank: 55, DueDate: "2028-03-05", TotalAmount: 1084.75, Capital: 598.81, Interest: 485.94, RemainingCapital: 157003.9},
|
||||
{Rank: 56, DueDate: "2028-04-05", TotalAmount: 1084.75, Capital: 600.65, Interest: 484.1, RemainingCapital: 156403.25},
|
||||
{Rank: 57, DueDate: "2028-05-05", TotalAmount: 1084.75, Capital: 602.51, Interest: 482.24, RemainingCapital: 155800.74},
|
||||
{Rank: 58, DueDate: "2028-06-05", TotalAmount: 1084.75, Capital: 604.36, Interest: 480.39, RemainingCapital: 155196.38},
|
||||
{Rank: 59, DueDate: "2028-07-05", TotalAmount: 1084.75, Capital: 606.23, Interest: 478.52, RemainingCapital: 154590.15},
|
||||
{Rank: 60, DueDate: "2028-08-05", TotalAmount: 1084.75, Capital: 608.1, Interest: 476.65, RemainingCapital: 153982.05},
|
||||
{Rank: 61, DueDate: "2028-09-05", TotalAmount: 1084.75, Capital: 609.97, Interest: 474.78, RemainingCapital: 153372.08},
|
||||
{Rank: 62, DueDate: "2028-10-05", TotalAmount: 1084.75, Capital: 611.85, Interest: 472.9, RemainingCapital: 152760.23},
|
||||
{Rank: 63, DueDate: "2028-11-05", TotalAmount: 1084.75, Capital: 613.74, Interest: 471.01, RemainingCapital: 152146.49},
|
||||
{Rank: 64, DueDate: "2028-12-05", TotalAmount: 1084.75, Capital: 615.63, Interest: 469.12, RemainingCapital: 151530.86},
|
||||
{Rank: 65, DueDate: "2029-01-05", TotalAmount: 1084.75, Capital: 617.53, Interest: 467.22, RemainingCapital: 150913.33},
|
||||
{Rank: 66, DueDate: "2029-02-05", TotalAmount: 1084.75, Capital: 619.43, Interest: 465.32, RemainingCapital: 150293.9},
|
||||
{Rank: 67, DueDate: "2029-03-05", TotalAmount: 1084.75, Capital: 621.34, Interest: 463.41, RemainingCapital: 149672.56},
|
||||
{Rank: 68, DueDate: "2029-04-05", TotalAmount: 1084.75, Capital: 623.26, Interest: 461.49, RemainingCapital: 149049.3},
|
||||
{Rank: 69, DueDate: "2029-05-05", TotalAmount: 1084.75, Capital: 625.18, Interest: 459.57, RemainingCapital: 148424.12},
|
||||
{Rank: 70, DueDate: "2029-06-05", TotalAmount: 1084.75, Capital: 627.11, Interest: 457.64, RemainingCapital: 147797.01},
|
||||
{Rank: 71, DueDate: "2029-07-05", TotalAmount: 1084.75, Capital: 629.04, Interest: 455.71, RemainingCapital: 147167.97},
|
||||
{Rank: 72, DueDate: "2029-08-05", TotalAmount: 1084.75, Capital: 630.98, Interest: 453.77, RemainingCapital: 146536.99},
|
||||
{Rank: 73, DueDate: "2029-09-05", TotalAmount: 1084.75, Capital: 632.93, Interest: 451.82, RemainingCapital: 145904.06},
|
||||
{Rank: 74, DueDate: "2029-10-05", TotalAmount: 1084.75, Capital: 634.88, Interest: 449.87, RemainingCapital: 145269.18},
|
||||
{Rank: 75, DueDate: "2029-11-05", TotalAmount: 1084.75, Capital: 636.84, Interest: 447.91, RemainingCapital: 144632.34},
|
||||
{Rank: 76, DueDate: "2029-12-05", TotalAmount: 1084.75, Capital: 638.8, Interest: 445.95, RemainingCapital: 143993.54},
|
||||
{Rank: 77, DueDate: "2030-01-05", TotalAmount: 1084.75, Capital: 640.77, Interest: 443.98, RemainingCapital: 143352.77},
|
||||
{Rank: 78, DueDate: "2030-02-05", TotalAmount: 1084.75, Capital: 642.75, Interest: 442.0, RemainingCapital: 142710.02},
|
||||
{Rank: 79, DueDate: "2030-03-05", TotalAmount: 1084.75, Capital: 644.73, Interest: 440.02, RemainingCapital: 142065.29},
|
||||
{Rank: 80, DueDate: "2030-04-05", TotalAmount: 1084.75, Capital: 646.72, Interest: 438.03, RemainingCapital: 141418.57},
|
||||
{Rank: 89, DueDate: "2031-01-05", TotalAmount: 1084.75, Capital: 664.88, Interest: 419.87, RemainingCapital: 135507.66},
|
||||
{Rank: 90, DueDate: "2031-02-05", TotalAmount: 1084.75, Capital: 666.93, Interest: 417.82, RemainingCapital: 134840.73},
|
||||
{Rank: 91, DueDate: "2031-03-05", TotalAmount: 1084.75, Capital: 668.99, Interest: 415.76, RemainingCapital: 134171.74},
|
||||
{Rank: 92, DueDate: "2031-04-05", TotalAmount: 1084.75, Capital: 671.05, Interest: 413.7, RemainingCapital: 133500.69},
|
||||
{Rank: 93, DueDate: "2031-05-05", TotalAmount: 1084.75, Capital: 673.12, Interest: 411.63, RemainingCapital: 132827.57},
|
||||
{Rank: 94, DueDate: "2031-06-05", TotalAmount: 1084.75, Capital: 675.2, Interest: 409.55, RemainingCapital: 132152.37},
|
||||
{Rank: 95, DueDate: "2031-07-05", TotalAmount: 1084.75, Capital: 677.28, Interest: 407.47, RemainingCapital: 131475.09},
|
||||
{Rank: 96, DueDate: "2031-08-05", TotalAmount: 1084.75, Capital: 679.37, Interest: 405.38, RemainingCapital: 130795.72},
|
||||
{Rank: 97, DueDate: "2031-09-05", TotalAmount: 1084.75, Capital: 681.46, Interest: 403.29, RemainingCapital: 130114.26},
|
||||
{Rank: 98, DueDate: "2031-10-05", TotalAmount: 1084.75, Capital: 683.56, Interest: 401.19, RemainingCapital: 129430.7},
|
||||
{Rank: 99, DueDate: "2031-11-05", TotalAmount: 1084.75, Capital: 685.67, Interest: 399.08, RemainingCapital: 128745.03},
|
||||
{Rank: 100, DueDate: "2031-12-05", TotalAmount: 1084.75, Capital: 687.79, Interest: 396.96, RemainingCapital: 128057.24},
|
||||
{Rank: 101, DueDate: "2032-01-05", TotalAmount: 1084.75, Capital: 689.91, Interest: 394.84, RemainingCapital: 127367.33},
|
||||
{Rank: 102, DueDate: "2032-02-05", TotalAmount: 1084.75, Capital: 692.03, Interest: 392.72, RemainingCapital: 126675.3},
|
||||
{Rank: 103, DueDate: "2032-03-05", TotalAmount: 1084.75, Capital: 694.17, Interest: 390.58, RemainingCapital: 125981.13},
|
||||
{Rank: 104, DueDate: "2032-04-05", TotalAmount: 1084.75, Capital: 696.31, Interest: 388.44, RemainingCapital: 125284.82},
|
||||
{Rank: 105, DueDate: "2032-05-05", TotalAmount: 1084.75, Capital: 698.46, Interest: 386.29, RemainingCapital: 124586.36},
|
||||
{Rank: 106, DueDate: "2032-06-05", TotalAmount: 1084.75, Capital: 700.61, Interest: 384.14, RemainingCapital: 123885.75},
|
||||
{Rank: 107, DueDate: "2032-07-05", TotalAmount: 1084.75, Capital: 702.77, Interest: 381.98, RemainingCapital: 123182.98},
|
||||
{Rank: 108, DueDate: "2032-08-05", TotalAmount: 1084.75, Capital: 704.94, Interest: 379.81, RemainingCapital: 122478.04},
|
||||
{Rank: 109, DueDate: "2032-09-05", TotalAmount: 1084.75, Capital: 707.11, Interest: 377.64, RemainingCapital: 121770.93},
|
||||
{Rank: 110, DueDate: "2032-10-05", TotalAmount: 1084.75, Capital: 709.29, Interest: 375.46, RemainingCapital: 121061.64},
|
||||
{Rank: 111, DueDate: "2032-11-05", TotalAmount: 1084.75, Capital: 711.48, Interest: 373.27, RemainingCapital: 120350.16},
|
||||
{Rank: 112, DueDate: "2032-12-05", TotalAmount: 1084.75, Capital: 713.67, Interest: 371.08, RemainingCapital: 119636.49},
|
||||
{Rank: 113, DueDate: "2033-01-05", TotalAmount: 1084.75, Capital: 715.87, Interest: 368.88, RemainingCapital: 118920.62},
|
||||
{Rank: 114, DueDate: "2033-02-05", TotalAmount: 1084.75, Capital: 718.08, Interest: 366.67, RemainingCapital: 118202.54},
|
||||
{Rank: 115, DueDate: "2033-03-05", TotalAmount: 1084.75, Capital: 720.29, Interest: 364.46, RemainingCapital: 117482.25},
|
||||
{Rank: 116, DueDate: "2033-04-05", TotalAmount: 1084.75, Capital: 722.51, Interest: 362.24, RemainingCapital: 116759.74},
|
||||
{Rank: 117, DueDate: "2033-05-05", TotalAmount: 1084.75, Capital: 724.74, Interest: 360.01, RemainingCapital: 116035.0},
|
||||
{Rank: 118, DueDate: "2033-06-05", TotalAmount: 1084.75, Capital: 726.98, Interest: 357.77, RemainingCapital: 115308.02},
|
||||
{Rank: 119, DueDate: "2033-07-05", TotalAmount: 1084.75, Capital: 729.22, Interest: 355.53, RemainingCapital: 114578.8},
|
||||
{Rank: 120, DueDate: "2033-08-05", TotalAmount: 1084.75, Capital: 731.47, Interest: 353.28, RemainingCapital: 113847.33},
|
||||
{Rank: 121, DueDate: "2033-09-05", TotalAmount: 1084.75, Capital: 733.72, Interest: 351.03, RemainingCapital: 113113.61},
|
||||
{Rank: 122, DueDate: "2033-10-05", TotalAmount: 1084.75, Capital: 735.98, Interest: 348.77, RemainingCapital: 112377.63},
|
||||
{Rank: 123, DueDate: "2033-11-05", TotalAmount: 1084.75, Capital: 738.25, Interest: 346.5, RemainingCapital: 111639.38},
|
||||
{Rank: 124, DueDate: "2033-12-05", TotalAmount: 1084.75, Capital: 740.53, Interest: 344.22, RemainingCapital: 110898.85},
|
||||
{Rank: 125, DueDate: "2034-01-05", TotalAmount: 1084.75, Capital: 742.81, Interest: 341.94, RemainingCapital: 110156.04},
|
||||
{Rank: 126, DueDate: "2034-02-05", TotalAmount: 1084.75, Capital: 745.1, Interest: 339.65, RemainingCapital: 109410.94},
|
||||
{Rank: 127, DueDate: "2034-03-05", TotalAmount: 1084.75, Capital: 747.4, Interest: 337.35, RemainingCapital: 108663.54},
|
||||
{Rank: 128, DueDate: "2034-04-05", TotalAmount: 1084.75, Capital: 749.7, Interest: 335.05, RemainingCapital: 107913.84},
|
||||
{Rank: 129, DueDate: "2034-05-05", TotalAmount: 1084.75, Capital: 752.02, Interest: 332.73, RemainingCapital: 107161.82},
|
||||
{Rank: 130, DueDate: "2034-06-05", TotalAmount: 1084.75, Capital: 754.33, Interest: 330.42, RemainingCapital: 106407.49},
|
||||
{Rank: 131, DueDate: "2034-07-05", TotalAmount: 1084.75, Capital: 756.66, Interest: 328.09, RemainingCapital: 105650.83},
|
||||
{Rank: 132, DueDate: "2034-08-05", TotalAmount: 1084.75, Capital: 758.99, Interest: 325.76, RemainingCapital: 104891.84},
|
||||
{Rank: 137, DueDate: "2035-01-05", TotalAmount: 1084.75, Capital: 770.77, Interest: 313.98, RemainingCapital: 101061.62},
|
||||
{Rank: 138, DueDate: "2035-02-05", TotalAmount: 1084.75, Capital: 773.14, Interest: 311.61, RemainingCapital: 100288.48},
|
||||
{Rank: 139, DueDate: "2035-03-05", TotalAmount: 1084.75, Capital: 775.53, Interest: 309.22, RemainingCapital: 99512.95},
|
||||
{Rank: 140, DueDate: "2035-04-05", TotalAmount: 1084.75, Capital: 777.92, Interest: 306.83, RemainingCapital: 98735.03},
|
||||
{Rank: 141, DueDate: "2035-05-05", TotalAmount: 1084.75, Capital: 780.32, Interest: 304.43, RemainingCapital: 97954.71},
|
||||
{Rank: 142, DueDate: "2035-06-05", TotalAmount: 1084.75, Capital: 782.72, Interest: 302.03, RemainingCapital: 97171.99},
|
||||
{Rank: 143, DueDate: "2035-07-05", TotalAmount: 1084.75, Capital: 785.14, Interest: 299.61, RemainingCapital: 96386.85},
|
||||
{Rank: 144, DueDate: "2035-08-05", TotalAmount: 1084.75, Capital: 787.56, Interest: 297.19, RemainingCapital: 95599.29},
|
||||
{Rank: 145, DueDate: "2035-09-05", TotalAmount: 1084.75, Capital: 789.99, Interest: 294.76, RemainingCapital: 94809.3},
|
||||
{Rank: 146, DueDate: "2035-10-05", TotalAmount: 1084.75, Capital: 792.42, Interest: 292.33, RemainingCapital: 94016.88},
|
||||
{Rank: 147, DueDate: "2035-11-05", TotalAmount: 1084.75, Capital: 794.86, Interest: 289.89, RemainingCapital: 93222.02},
|
||||
{Rank: 148, DueDate: "2035-12-05", TotalAmount: 1084.75, Capital: 797.32, Interest: 287.43, RemainingCapital: 92424.7},
|
||||
{Rank: 149, DueDate: "2036-01-05", TotalAmount: 1084.75, Capital: 799.77, Interest: 284.98, RemainingCapital: 91624.93},
|
||||
{Rank: 150, DueDate: "2036-02-05", TotalAmount: 1084.75, Capital: 802.24, Interest: 282.51, RemainingCapital: 90822.69},
|
||||
{Rank: 151, DueDate: "2036-03-05", TotalAmount: 1084.75, Capital: 804.71, Interest: 280.04, RemainingCapital: 90017.98},
|
||||
{Rank: 152, DueDate: "2036-04-05", TotalAmount: 1084.75, Capital: 807.19, Interest: 277.56, RemainingCapital: 89210.79},
|
||||
{Rank: 153, DueDate: "2036-05-05", TotalAmount: 1084.75, Capital: 809.68, Interest: 275.07, RemainingCapital: 88401.11},
|
||||
{Rank: 154, DueDate: "2036-06-05", TotalAmount: 1084.75, Capital: 812.18, Interest: 272.57, RemainingCapital: 87588.93},
|
||||
{Rank: 155, DueDate: "2036-07-05", TotalAmount: 1084.75, Capital: 814.68, Interest: 270.07, RemainingCapital: 86774.25},
|
||||
{Rank: 156, DueDate: "2036-08-05", TotalAmount: 1084.75, Capital: 817.2, Interest: 267.55, RemainingCapital: 85957.05},
|
||||
{Rank: 157, DueDate: "2036-09-05", TotalAmount: 1084.75, Capital: 819.72, Interest: 265.03, RemainingCapital: 85137.33},
|
||||
{Rank: 158, DueDate: "2036-10-05", TotalAmount: 1084.75, Capital: 822.24, Interest: 262.51, RemainingCapital: 84315.09},
|
||||
{Rank: 159, DueDate: "2036-11-05", TotalAmount: 1084.75, Capital: 824.78, Interest: 259.97, RemainingCapital: 83490.31},
|
||||
{Rank: 160, DueDate: "2036-12-05", TotalAmount: 1084.75, Capital: 827.32, Interest: 257.43, RemainingCapital: 82662.99},
|
||||
{Rank: 161, DueDate: "2037-01-05", TotalAmount: 1084.75, Capital: 829.87, Interest: 254.88, RemainingCapital: 81833.12},
|
||||
{Rank: 162, DueDate: "2037-02-05", TotalAmount: 1084.75, Capital: 832.43, Interest: 252.32, RemainingCapital: 81000.69},
|
||||
{Rank: 163, DueDate: "2037-03-05", TotalAmount: 1084.75, Capital: 835.0, Interest: 249.75, RemainingCapital: 80165.69},
|
||||
{Rank: 164, DueDate: "2037-04-05", TotalAmount: 1084.75, Capital: 837.57, Interest: 247.18, RemainingCapital: 79328.12},
|
||||
{Rank: 165, DueDate: "2037-05-05", TotalAmount: 1084.75, Capital: 840.15, Interest: 244.6, RemainingCapital: 78487.97},
|
||||
{Rank: 166, DueDate: "2037-06-05", TotalAmount: 1084.75, Capital: 842.75, Interest: 242.0, RemainingCapital: 77645.22},
|
||||
{Rank: 167, DueDate: "2037-07-05", TotalAmount: 1084.75, Capital: 845.34, Interest: 239.41, RemainingCapital: 76799.88},
|
||||
{Rank: 168, DueDate: "2037-08-05", TotalAmount: 1084.75, Capital: 847.95, Interest: 236.8, RemainingCapital: 75951.93},
|
||||
{Rank: 169, DueDate: "2037-09-05", TotalAmount: 1084.75, Capital: 850.56, Interest: 234.19, RemainingCapital: 75101.37},
|
||||
{Rank: 170, DueDate: "2037-10-05", TotalAmount: 1084.75, Capital: 853.19, Interest: 231.56, RemainingCapital: 74248.18},
|
||||
{Rank: 171, DueDate: "2037-11-05", TotalAmount: 1084.75, Capital: 855.82, Interest: 228.93, RemainingCapital: 73392.36},
|
||||
{Rank: 172, DueDate: "2037-12-05", TotalAmount: 1084.75, Capital: 858.46, Interest: 226.29, RemainingCapital: 72533.9},
|
||||
{Rank: 173, DueDate: "2038-01-05", TotalAmount: 1084.75, Capital: 861.1, Interest: 223.65, RemainingCapital: 71672.8},
|
||||
{Rank: 174, DueDate: "2038-02-05", TotalAmount: 1084.75, Capital: 863.76, Interest: 220.99, RemainingCapital: 70809.04},
|
||||
{Rank: 175, DueDate: "2038-03-05", TotalAmount: 1084.75, Capital: 866.42, Interest: 218.33, RemainingCapital: 69942.62},
|
||||
{Rank: 176, DueDate: "2038-04-05", TotalAmount: 1084.75, Capital: 869.09, Interest: 215.66, RemainingCapital: 69073.53},
|
||||
{Rank: 177, DueDate: "2038-05-05", TotalAmount: 1084.75, Capital: 871.77, Interest: 212.98, RemainingCapital: 68201.76},
|
||||
{Rank: 178, DueDate: "2038-06-05", TotalAmount: 1084.75, Capital: 874.46, Interest: 210.29, RemainingCapital: 67327.3},
|
||||
{Rank: 179, DueDate: "2038-07-05", TotalAmount: 1084.75, Capital: 877.16, Interest: 207.59, RemainingCapital: 66450.14},
|
||||
{Rank: 180, DueDate: "2038-08-05", TotalAmount: 1084.75, Capital: 879.86, Interest: 204.89, RemainingCapital: 65570.28},
|
||||
{Rank: 181, DueDate: "2038-09-05", TotalAmount: 1084.75, Capital: 882.57, Interest: 202.18, RemainingCapital: 64687.71},
|
||||
{Rank: 182, DueDate: "2038-10-05", TotalAmount: 1084.75, Capital: 885.3, Interest: 199.45, RemainingCapital: 63802.41},
|
||||
{Rank: 183, DueDate: "2038-11-05", TotalAmount: 1084.75, Capital: 888.03, Interest: 196.72, RemainingCapital: 62914.38},
|
||||
{Rank: 184, DueDate: "2038-12-05", TotalAmount: 1084.75, Capital: 890.76, Interest: 193.99, RemainingCapital: 62023.62},
|
||||
{Rank: 185, DueDate: "2039-01-05", TotalAmount: 1084.75, Capital: 893.51, Interest: 191.24, RemainingCapital: 61130.11},
|
||||
{Rank: 186, DueDate: "2039-02-05", TotalAmount: 1084.75, Capital: 896.27, Interest: 188.48, RemainingCapital: 60233.84},
|
||||
{Rank: 187, DueDate: "2039-03-05", TotalAmount: 1084.75, Capital: 899.03, Interest: 185.72, RemainingCapital: 59334.81},
|
||||
{Rank: 188, DueDate: "2039-04-05", TotalAmount: 1084.75, Capital: 901.8, Interest: 182.95, RemainingCapital: 58433.01},
|
||||
{Rank: 189, DueDate: "2039-05-05", TotalAmount: 1084.75, Capital: 904.58, Interest: 180.17, RemainingCapital: 57528.43},
|
||||
{Rank: 190, DueDate: "2039-06-05", TotalAmount: 1084.75, Capital: 907.37, Interest: 177.38, RemainingCapital: 56621.06},
|
||||
{Rank: 191, DueDate: "2039-07-05", TotalAmount: 1084.75, Capital: 910.17, Interest: 174.58, RemainingCapital: 55710.89},
|
||||
{Rank: 192, DueDate: "2039-08-05", TotalAmount: 1084.75, Capital: 912.97, Interest: 171.78, RemainingCapital: 54797.92},
|
||||
{Rank: 193, DueDate: "2039-09-05", TotalAmount: 1084.75, Capital: 915.79, Interest: 168.96, RemainingCapital: 53882.13},
|
||||
{Rank: 194, DueDate: "2039-10-05", TotalAmount: 1084.75, Capital: 918.61, Interest: 166.14, RemainingCapital: 52963.52},
|
||||
{Rank: 195, DueDate: "2039-11-05", TotalAmount: 1084.75, Capital: 921.45, Interest: 163.3, RemainingCapital: 52042.07},
|
||||
{Rank: 196, DueDate: "2039-12-05", TotalAmount: 1084.75, Capital: 924.29, Interest: 160.46, RemainingCapital: 51117.78},
|
||||
{Rank: 197, DueDate: "2040-01-05", TotalAmount: 1084.75, Capital: 927.14, Interest: 157.61, RemainingCapital: 50190.64},
|
||||
{Rank: 198, DueDate: "2040-02-05", TotalAmount: 1084.75, Capital: 930.0, Interest: 154.75, RemainingCapital: 49260.64},
|
||||
{Rank: 199, DueDate: "2040-03-05", TotalAmount: 1084.75, Capital: 932.86, Interest: 151.89, RemainingCapital: 48327.78},
|
||||
{Rank: 200, DueDate: "2040-04-05", TotalAmount: 1084.75, Capital: 935.74, Interest: 149.01, RemainingCapital: 47392.04},
|
||||
{Rank: 201, DueDate: "2040-05-05", TotalAmount: 1084.75, Capital: 938.62, Interest: 146.13, RemainingCapital: 46453.42},
|
||||
{Rank: 202, DueDate: "2040-06-05", TotalAmount: 1084.75, Capital: 941.52, Interest: 143.23, RemainingCapital: 45511.9},
|
||||
{Rank: 203, DueDate: "2040-07-05", TotalAmount: 1084.75, Capital: 944.42, Interest: 140.33, RemainingCapital: 44567.48},
|
||||
{Rank: 204, DueDate: "2040-08-05", TotalAmount: 1084.75, Capital: 947.33, Interest: 137.42, RemainingCapital: 43620.15},
|
||||
{Rank: 205, DueDate: "2040-09-05", TotalAmount: 1084.75, Capital: 950.25, Interest: 134.5, RemainingCapital: 42669.9},
|
||||
{Rank: 206, DueDate: "2040-10-05", TotalAmount: 1084.75, Capital: 953.18, Interest: 131.57, RemainingCapital: 41716.72},
|
||||
{Rank: 207, DueDate: "2040-11-05", TotalAmount: 1084.75, Capital: 956.12, Interest: 128.63, RemainingCapital: 40760.6},
|
||||
{Rank: 208, DueDate: "2040-12-05", TotalAmount: 1084.75, Capital: 959.07, Interest: 125.68, RemainingCapital: 39801.53},
|
||||
{Rank: 209, DueDate: "2041-01-05", TotalAmount: 1084.75, Capital: 962.03, Interest: 122.72, RemainingCapital: 38839.5},
|
||||
{Rank: 210, DueDate: "2041-02-05", TotalAmount: 1084.75, Capital: 964.99, Interest: 119.76, RemainingCapital: 37874.51},
|
||||
{Rank: 211, DueDate: "2041-03-05", TotalAmount: 1084.75, Capital: 967.97, Interest: 116.78, RemainingCapital: 36906.54},
|
||||
{Rank: 212, DueDate: "2041-04-05", TotalAmount: 1084.75, Capital: 970.95, Interest: 113.8, RemainingCapital: 35935.59},
|
||||
{Rank: 213, DueDate: "2041-05-05", TotalAmount: 1084.75, Capital: 973.95, Interest: 110.8, RemainingCapital: 34961.64},
|
||||
{Rank: 214, DueDate: "2041-06-05", TotalAmount: 1084.75, Capital: 976.95, Interest: 107.8, RemainingCapital: 33984.69},
|
||||
{Rank: 215, DueDate: "2041-07-05", TotalAmount: 1084.75, Capital: 979.96, Interest: 104.79, RemainingCapital: 33004.73},
|
||||
{Rank: 216, DueDate: "2041-08-05", TotalAmount: 1084.75, Capital: 982.99, Interest: 101.76, RemainingCapital: 32021.74},
|
||||
{Rank: 217, DueDate: "2041-09-05", TotalAmount: 1084.75, Capital: 986.02, Interest: 98.73, RemainingCapital: 31035.72},
|
||||
{Rank: 218, DueDate: "2041-10-05", TotalAmount: 1084.75, Capital: 989.06, Interest: 95.69, RemainingCapital: 30046.66},
|
||||
{Rank: 219, DueDate: "2041-11-05", TotalAmount: 1084.75, Capital: 992.11, Interest: 92.64, RemainingCapital: 29054.55},
|
||||
{Rank: 220, DueDate: "2041-12-05", TotalAmount: 1084.75, Capital: 995.17, Interest: 89.58, RemainingCapital: 28059.38},
|
||||
{Rank: 221, DueDate: "2042-01-05", TotalAmount: 1084.75, Capital: 998.23, Interest: 86.52, RemainingCapital: 27061.15},
|
||||
{Rank: 222, DueDate: "2042-02-05", TotalAmount: 1084.75, Capital: 1001.31, Interest: 83.44, RemainingCapital: 26059.84},
|
||||
{Rank: 223, DueDate: "2042-03-05", TotalAmount: 1084.75, Capital: 1004.4, Interest: 80.35, RemainingCapital: 25055.44},
|
||||
{Rank: 224, DueDate: "2042-04-05", TotalAmount: 1084.75, Capital: 1007.5, Interest: 77.25, RemainingCapital: 24047.94},
|
||||
{Rank: 225, DueDate: "2042-05-05", TotalAmount: 1084.75, Capital: 1010.6, Interest: 74.15, RemainingCapital: 23037.34},
|
||||
{Rank: 226, DueDate: "2042-06-05", TotalAmount: 1084.75, Capital: 1013.72, Interest: 71.03, RemainingCapital: 22023.62},
|
||||
{Rank: 227, DueDate: "2042-07-05", TotalAmount: 1084.75, Capital: 1016.84, Interest: 67.91, RemainingCapital: 21006.78},
|
||||
{Rank: 228, DueDate: "2042-08-05", TotalAmount: 1084.75, Capital: 1019.98, Interest: 64.77, RemainingCapital: 19986.8},
|
||||
{Rank: 229, DueDate: "2042-09-05", TotalAmount: 1084.75, Capital: 1023.12, Interest: 61.63, RemainingCapital: 18963.68},
|
||||
{Rank: 230, DueDate: "2042-10-05", TotalAmount: 1084.75, Capital: 1026.28, Interest: 58.47, RemainingCapital: 17937.4},
|
||||
{Rank: 231, DueDate: "2042-11-05", TotalAmount: 1084.75, Capital: 1029.44, Interest: 55.31, RemainingCapital: 16907.96},
|
||||
{Rank: 232, DueDate: "2042-12-05", TotalAmount: 1084.75, Capital: 1032.62, Interest: 52.13, RemainingCapital: 15875.34},
|
||||
{Rank: 233, DueDate: "2043-01-05", TotalAmount: 1084.75, Capital: 1035.8, Interest: 48.95, RemainingCapital: 14839.54},
|
||||
{Rank: 234, DueDate: "2043-02-05", TotalAmount: 1084.75, Capital: 1038.99, Interest: 45.76, RemainingCapital: 13800.55},
|
||||
{Rank: 235, DueDate: "2043-03-05", TotalAmount: 1084.75, Capital: 1042.2, Interest: 42.55, RemainingCapital: 12758.35},
|
||||
{Rank: 236, DueDate: "2043-04-05", TotalAmount: 1084.75, Capital: 1045.41, Interest: 39.34, RemainingCapital: 11712.94},
|
||||
{Rank: 237, DueDate: "2043-05-05", TotalAmount: 1084.75, Capital: 1048.64, Interest: 36.11, RemainingCapital: 10664.3},
|
||||
{Rank: 238, DueDate: "2043-06-05", TotalAmount: 1084.75, Capital: 1051.87, Interest: 32.88, RemainingCapital: 9612.43},
|
||||
{Rank: 239, DueDate: "2043-07-05", TotalAmount: 1084.75, Capital: 1055.11, Interest: 29.64, RemainingCapital: 8557.32},
|
||||
{Rank: 240, DueDate: "2043-08-05", TotalAmount: 1084.75, Capital: 1058.36, Interest: 26.39, RemainingCapital: 7498.96},
|
||||
{Rank: 241, DueDate: "2043-09-05", TotalAmount: 1084.75, Capital: 1061.63, Interest: 23.12, RemainingCapital: 6437.33},
|
||||
{Rank: 242, DueDate: "2043-10-05", TotalAmount: 1084.75, Capital: 1064.9, Interest: 19.85, RemainingCapital: 5372.43},
|
||||
{Rank: 243, DueDate: "2043-11-05", TotalAmount: 1084.75, Capital: 1068.19, Interest: 16.56, RemainingCapital: 4304.24},
|
||||
{Rank: 244, DueDate: "2043-12-05", TotalAmount: 1084.75, Capital: 1071.48, Interest: 13.27, RemainingCapital: 3232.76},
|
||||
{Rank: 245, DueDate: "2044-01-05", TotalAmount: 1084.75, Capital: 1074.78, Interest: 9.97, RemainingCapital: 2157.98},
|
||||
{Rank: 246, DueDate: "2044-02-05", TotalAmount: 1084.75, Capital: 1078.1, Interest: 6.65, RemainingCapital: 1079.88},
|
||||
{Rank: 247, DueDate: "2044-03-05", TotalAmount: 1084.75, Capital: 1079.88, Interest: 4.87, RemainingCapital: 0.0},
|
||||
}
|
||||
}
|
||||
|
||||
// Prêt 781728E : 216 lignes
|
||||
func GetLoan781728Lines() []LoanLine {
|
||||
return []LoanLine{
|
||||
{Rank: 8, DueDate: "2024-04-05", TotalAmount: 725.74, Capital: 346.66, Interest: 379.08, RemainingCapital: 122599.34},
|
||||
{Rank: 9, DueDate: "2024-05-05", TotalAmount: 725.74, Capital: 347.73, Interest: 378.01, RemainingCapital: 122251.61},
|
||||
{Rank: 10, DueDate: "2024-06-05", TotalAmount: 725.74, Capital: 348.8, Interest: 376.94, RemainingCapital: 121902.81},
|
||||
{Rank: 11, DueDate: "2024-07-05", TotalAmount: 725.74, Capital: 349.87, Interest: 375.87, RemainingCapital: 121552.94},
|
||||
{Rank: 12, DueDate: "2024-08-05", TotalAmount: 725.74, Capital: 350.95, Interest: 374.79, RemainingCapital: 121201.99},
|
||||
{Rank: 13, DueDate: "2024-09-05", TotalAmount: 725.74, Capital: 352.03, Interest: 373.71, RemainingCapital: 120849.96},
|
||||
{Rank: 14, DueDate: "2024-10-05", TotalAmount: 725.74, Capital: 353.12, Interest: 372.62, RemainingCapital: 120496.84},
|
||||
{Rank: 15, DueDate: "2024-11-05", TotalAmount: 725.74, Capital: 354.21, Interest: 371.53, RemainingCapital: 120142.63},
|
||||
{Rank: 16, DueDate: "2024-12-05", TotalAmount: 725.74, Capital: 355.3, Interest: 370.44, RemainingCapital: 119787.33},
|
||||
{Rank: 17, DueDate: "2025-01-05", TotalAmount: 725.74, Capital: 356.4, Interest: 369.34, RemainingCapital: 119430.93},
|
||||
{Rank: 18, DueDate: "2025-02-05", TotalAmount: 725.74, Capital: 357.49, Interest: 368.25, RemainingCapital: 119073.44},
|
||||
{Rank: 19, DueDate: "2025-03-05", TotalAmount: 725.74, Capital: 358.6, Interest: 367.14, RemainingCapital: 118714.84},
|
||||
{Rank: 20, DueDate: "2025-04-05", TotalAmount: 725.74, Capital: 359.7, Interest: 366.04, RemainingCapital: 118355.14},
|
||||
{Rank: 21, DueDate: "2025-05-05", TotalAmount: 725.74, Capital: 360.81, Interest: 364.93, RemainingCapital: 117994.33},
|
||||
{Rank: 22, DueDate: "2025-06-05", TotalAmount: 725.74, Capital: 361.92, Interest: 363.82, RemainingCapital: 117632.41},
|
||||
{Rank: 23, DueDate: "2025-07-05", TotalAmount: 725.74, Capital: 363.04, Interest: 362.7, RemainingCapital: 117269.37},
|
||||
{Rank: 24, DueDate: "2025-08-05", TotalAmount: 725.74, Capital: 364.16, Interest: 361.58, RemainingCapital: 116905.21},
|
||||
{Rank: 25, DueDate: "2025-09-05", TotalAmount: 725.74, Capital: 365.28, Interest: 360.46, RemainingCapital: 116539.93},
|
||||
{Rank: 26, DueDate: "2025-10-05", TotalAmount: 725.74, Capital: 366.41, Interest: 359.33, RemainingCapital: 116173.52},
|
||||
{Rank: 27, DueDate: "2025-11-05", TotalAmount: 725.74, Capital: 367.54, Interest: 358.2, RemainingCapital: 115805.98},
|
||||
{Rank: 28, DueDate: "2025-12-05", TotalAmount: 725.74, Capital: 368.67, Interest: 357.07, RemainingCapital: 115437.31},
|
||||
{Rank: 29, DueDate: "2026-01-05", TotalAmount: 725.74, Capital: 369.81, Interest: 355.93, RemainingCapital: 115067.50},
|
||||
{Rank: 30, DueDate: "2026-02-05", TotalAmount: 725.74, Capital: 370.95, Interest: 354.79, RemainingCapital: 114696.55},
|
||||
{Rank: 31, DueDate: "2026-03-05", TotalAmount: 725.74, Capital: 372.09, Interest: 353.65, RemainingCapital: 114324.46},
|
||||
{Rank: 32, DueDate: "2026-04-05", TotalAmount: 725.74, Capital: 373.24, Interest: 352.50, RemainingCapital: 113951.22},
|
||||
{Rank: 33, DueDate: "2026-05-05", TotalAmount: 725.74, Capital: 374.39, Interest: 351.35, RemainingCapital: 113576.83},
|
||||
{Rank: 34, DueDate: "2026-06-05", TotalAmount: 725.74, Capital: 375.54, Interest: 350.20, RemainingCapital: 113201.29},
|
||||
{Rank: 35, DueDate: "2026-07-05", TotalAmount: 725.74, Capital: 376.70, Interest: 349.04, RemainingCapital: 112824.59},
|
||||
{Rank: 36, DueDate: "2026-08-05", TotalAmount: 725.74, Capital: 377.86, Interest: 347.88, RemainingCapital: 112446.73},
|
||||
{Rank: 37, DueDate: "2026-09-05", TotalAmount: 725.74, Capital: 379.03, Interest: 346.71, RemainingCapital: 112067.70},
|
||||
{Rank: 38, DueDate: "2026-10-05", TotalAmount: 725.74, Capital: 380.20, Interest: 345.54, RemainingCapital: 111687.50},
|
||||
{Rank: 39, DueDate: "2026-11-05", TotalAmount: 725.74, Capital: 381.37, Interest: 344.37, RemainingCapital: 111306.13},
|
||||
{Rank: 40, DueDate: "2026-12-05", TotalAmount: 725.74, Capital: 382.55, Interest: 343.19, RemainingCapital: 110923.58},
|
||||
{Rank: 41, DueDate: "2027-01-05", TotalAmount: 725.74, Capital: 383.73, Interest: 342.01, RemainingCapital: 110539.85},
|
||||
{Rank: 42, DueDate: "2027-02-05", TotalAmount: 725.74, Capital: 384.91, Interest: 340.83, RemainingCapital: 110154.94},
|
||||
{Rank: 43, DueDate: "2027-03-05", TotalAmount: 725.74, Capital: 386.1, Interest: 339.64, RemainingCapital: 109768.84},
|
||||
{Rank: 44, DueDate: "2027-04-05", TotalAmount: 725.74, Capital: 387.29, Interest: 338.45, RemainingCapital: 109381.55},
|
||||
{Rank: 45, DueDate: "2027-05-05", TotalAmount: 725.74, Capital: 388.48, Interest: 337.26, RemainingCapital: 108993.07},
|
||||
{Rank: 46, DueDate: "2027-06-05", TotalAmount: 725.74, Capital: 389.68, Interest: 336.06, RemainingCapital: 108603.39},
|
||||
{Rank: 47, DueDate: "2027-07-05", TotalAmount: 725.74, Capital: 390.88, Interest: 334.86, RemainingCapital: 108212.51},
|
||||
{Rank: 48, DueDate: "2027-08-05", TotalAmount: 725.74, Capital: 392.08, Interest: 333.66, RemainingCapital: 107820.43},
|
||||
{Rank: 49, DueDate: "2027-09-05", TotalAmount: 725.74, Capital: 393.29, Interest: 332.45, RemainingCapital: 107427.14},
|
||||
{Rank: 50, DueDate: "2027-10-05", TotalAmount: 725.74, Capital: 394.51, Interest: 331.23, RemainingCapital: 107032.63},
|
||||
{Rank: 51, DueDate: "2027-11-05", TotalAmount: 725.74, Capital: 395.72, Interest: 330.02, RemainingCapital: 106636.91},
|
||||
{Rank: 52, DueDate: "2027-12-05", TotalAmount: 725.74, Capital: 396.94, Interest: 328.8, RemainingCapital: 106239.97},
|
||||
{Rank: 53, DueDate: "2028-01-05", TotalAmount: 725.74, Capital: 398.17, Interest: 327.57, RemainingCapital: 105841.8},
|
||||
{Rank: 54, DueDate: "2028-02-05", TotalAmount: 725.74, Capital: 399.39, Interest: 326.35, RemainingCapital: 105442.41},
|
||||
{Rank: 55, DueDate: "2028-03-05", TotalAmount: 725.74, Capital: 400.63, Interest: 325.11, RemainingCapital: 105041.78},
|
||||
{Rank: 56, DueDate: "2028-04-05", TotalAmount: 725.74, Capital: 401.86, Interest: 323.88, RemainingCapital: 104639.92},
|
||||
{Rank: 57, DueDate: "2028-05-05", TotalAmount: 725.74, Capital: 403.1, Interest: 322.64, RemainingCapital: 104236.82},
|
||||
{Rank: 58, DueDate: "2028-06-05", TotalAmount: 725.74, Capital: 404.34, Interest: 321.4, RemainingCapital: 103832.48},
|
||||
{Rank: 59, DueDate: "2028-07-05", TotalAmount: 725.74, Capital: 405.59, Interest: 320.15, RemainingCapital: 103426.89},
|
||||
{Rank: 60, DueDate: "2028-08-05", TotalAmount: 725.74, Capital: 406.84, Interest: 318.9, RemainingCapital: 103020.05},
|
||||
{Rank: 61, DueDate: "2028-09-05", TotalAmount: 725.74, Capital: 408.09, Interest: 317.65, RemainingCapital: 102611.96},
|
||||
{Rank: 62, DueDate: "2028-10-05", TotalAmount: 725.74, Capital: 409.35, Interest: 316.39, RemainingCapital: 102202.61},
|
||||
{Rank: 63, DueDate: "2028-11-05", TotalAmount: 725.74, Capital: 410.62, Interest: 315.12, RemainingCapital: 101791.99},
|
||||
{Rank: 64, DueDate: "2028-12-05", TotalAmount: 725.74, Capital: 411.88, Interest: 313.86, RemainingCapital: 101380.11},
|
||||
{Rank: 65, DueDate: "2029-01-05", TotalAmount: 725.74, Capital: 413.15, Interest: 312.59, RemainingCapital: 100966.96},
|
||||
{Rank: 66, DueDate: "2029-02-05", TotalAmount: 725.74, Capital: 414.43, Interest: 311.31, RemainingCapital: 100552.53},
|
||||
{Rank: 67, DueDate: "2029-03-05", TotalAmount: 725.74, Capital: 415.7, Interest: 310.04, RemainingCapital: 100136.83},
|
||||
{Rank: 68, DueDate: "2029-04-05", TotalAmount: 725.74, Capital: 416.98, Interest: 308.76, RemainingCapital: 99719.85},
|
||||
{Rank: 69, DueDate: "2029-05-05", TotalAmount: 725.74, Capital: 418.27, Interest: 307.47, RemainingCapital: 99301.58},
|
||||
{Rank: 70, DueDate: "2029-06-05", TotalAmount: 725.74, Capital: 419.56, Interest: 306.18, RemainingCapital: 98882.02},
|
||||
{Rank: 71, DueDate: "2029-07-05", TotalAmount: 725.74, Capital: 420.85, Interest: 304.89, RemainingCapital: 98461.17},
|
||||
{Rank: 72, DueDate: "2029-08-05", TotalAmount: 725.74, Capital: 422.15, Interest: 303.59, RemainingCapital: 98039.02},
|
||||
{Rank: 73, DueDate: "2029-09-05", TotalAmount: 725.74, Capital: 423.45, Interest: 302.29, RemainingCapital: 97615.57},
|
||||
{Rank: 74, DueDate: "2029-10-05", TotalAmount: 725.74, Capital: 424.76, Interest: 300.98, RemainingCapital: 97190.81},
|
||||
{Rank: 75, DueDate: "2029-11-05", TotalAmount: 725.74, Capital: 426.07, Interest: 299.67, RemainingCapital: 96764.74},
|
||||
{Rank: 76, DueDate: "2029-12-05", TotalAmount: 725.74, Capital: 427.38, Interest: 298.36, RemainingCapital: 96337.36},
|
||||
{Rank: 77, DueDate: "2030-01-05", TotalAmount: 725.74, Capital: 428.7, Interest: 297.04, RemainingCapital: 95908.66},
|
||||
{Rank: 78, DueDate: "2030-02-05", TotalAmount: 725.74, Capital: 430.02, Interest: 295.72, RemainingCapital: 95478.64},
|
||||
{Rank: 79, DueDate: "2030-03-05", TotalAmount: 725.74, Capital: 431.35, Interest: 294.39, RemainingCapital: 95047.29},
|
||||
{Rank: 80, DueDate: "2030-04-05", TotalAmount: 725.74, Capital: 432.68, Interest: 293.06, RemainingCapital: 94614.61},
|
||||
{Rank: 89, DueDate: "2031-01-05", TotalAmount: 725.74, Capital: 444.83, Interest: 280.91, RemainingCapital: 90659.99},
|
||||
{Rank: 90, DueDate: "2031-02-05", TotalAmount: 725.74, Capital: 446.21, Interest: 279.53, RemainingCapital: 90213.78},
|
||||
{Rank: 91, DueDate: "2031-03-05", TotalAmount: 725.74, Capital: 447.58, Interest: 278.16, RemainingCapital: 89766.2},
|
||||
{Rank: 92, DueDate: "2031-04-05", TotalAmount: 725.74, Capital: 448.96, Interest: 276.78, RemainingCapital: 89317.24},
|
||||
{Rank: 93, DueDate: "2031-05-05", TotalAmount: 725.74, Capital: 450.35, Interest: 275.39, RemainingCapital: 88866.89},
|
||||
{Rank: 94, DueDate: "2031-06-05", TotalAmount: 725.74, Capital: 451.73, Interest: 274.01, RemainingCapital: 88415.16},
|
||||
{Rank: 95, DueDate: "2031-07-05", TotalAmount: 725.74, Capital: 453.13, Interest: 272.61, RemainingCapital: 87962.03},
|
||||
{Rank: 96, DueDate: "2031-08-05", TotalAmount: 725.74, Capital: 454.52, Interest: 271.22, RemainingCapital: 87507.51},
|
||||
{Rank: 97, DueDate: "2031-09-05", TotalAmount: 725.74, Capital: 455.93, Interest: 269.81, RemainingCapital: 87051.58},
|
||||
{Rank: 98, DueDate: "2031-10-05", TotalAmount: 725.74, Capital: 457.33, Interest: 268.41, RemainingCapital: 86594.25},
|
||||
{Rank: 99, DueDate: "2031-11-05", TotalAmount: 725.74, Capital: 458.74, Interest: 267.0, RemainingCapital: 86135.51},
|
||||
{Rank: 100, DueDate: "2031-12-05", TotalAmount: 725.74, Capital: 460.16, Interest: 265.58, RemainingCapital: 85675.35},
|
||||
{Rank: 101, DueDate: "2032-01-05", TotalAmount: 725.74, Capital: 461.57, Interest: 264.17, RemainingCapital: 85213.78},
|
||||
{Rank: 102, DueDate: "2032-02-05", TotalAmount: 725.74, Capital: 463.0, Interest: 262.74, RemainingCapital: 84750.78},
|
||||
{Rank: 103, DueDate: "2032-03-05", TotalAmount: 725.74, Capital: 464.43, Interest: 261.31, RemainingCapital: 84286.35},
|
||||
{Rank: 104, DueDate: "2032-04-05", TotalAmount: 725.74, Capital: 465.86, Interest: 259.88, RemainingCapital: 83820.49},
|
||||
{Rank: 105, DueDate: "2032-05-05", TotalAmount: 725.74, Capital: 467.29, Interest: 258.45, RemainingCapital: 83353.2},
|
||||
{Rank: 106, DueDate: "2032-06-05", TotalAmount: 725.74, Capital: 468.73, Interest: 257.01, RemainingCapital: 82884.47},
|
||||
{Rank: 107, DueDate: "2032-07-05", TotalAmount: 725.74, Capital: 470.18, Interest: 255.56, RemainingCapital: 82414.29},
|
||||
{Rank: 108, DueDate: "2032-08-05", TotalAmount: 725.74, Capital: 471.63, Interest: 254.11, RemainingCapital: 81942.66},
|
||||
{Rank: 109, DueDate: "2032-09-05", TotalAmount: 725.74, Capital: 473.08, Interest: 252.66, RemainingCapital: 81469.58},
|
||||
{Rank: 110, DueDate: "2032-10-05", TotalAmount: 725.74, Capital: 474.54, Interest: 251.2, RemainingCapital: 80995.04},
|
||||
{Rank: 111, DueDate: "2032-11-05", TotalAmount: 725.74, Capital: 476.01, Interest: 249.73, RemainingCapital: 80519.03},
|
||||
{Rank: 112, DueDate: "2032-12-05", TotalAmount: 725.74, Capital: 477.47, Interest: 248.27, RemainingCapital: 80041.56},
|
||||
{Rank: 113, DueDate: "2033-01-05", TotalAmount: 725.74, Capital: 478.95, Interest: 246.79, RemainingCapital: 79562.61},
|
||||
{Rank: 114, DueDate: "2033-02-05", TotalAmount: 725.74, Capital: 480.42, Interest: 245.32, RemainingCapital: 79082.19},
|
||||
{Rank: 115, DueDate: "2033-03-05", TotalAmount: 725.74, Capital: 481.9, Interest: 243.84, RemainingCapital: 78600.29},
|
||||
{Rank: 116, DueDate: "2033-04-05", TotalAmount: 725.74, Capital: 483.39, Interest: 242.35, RemainingCapital: 78116.9},
|
||||
{Rank: 117, DueDate: "2033-05-05", TotalAmount: 725.74, Capital: 484.88, Interest: 240.86, RemainingCapital: 77632.02},
|
||||
{Rank: 118, DueDate: "2033-06-05", TotalAmount: 725.74, Capital: 486.37, Interest: 239.37, RemainingCapital: 77145.65},
|
||||
{Rank: 119, DueDate: "2033-07-05", TotalAmount: 725.74, Capital: 487.87, Interest: 237.87, RemainingCapital: 76657.78},
|
||||
{Rank: 120, DueDate: "2033-08-05", TotalAmount: 725.74, Capital: 489.38, Interest: 236.36, RemainingCapital: 76168.4},
|
||||
{Rank: 121, DueDate: "2033-09-05", TotalAmount: 725.74, Capital: 490.89, Interest: 234.85, RemainingCapital: 75677.51},
|
||||
{Rank: 122, DueDate: "2033-10-05", TotalAmount: 725.74, Capital: 492.4, Interest: 233.34, RemainingCapital: 75185.11},
|
||||
{Rank: 123, DueDate: "2033-11-05", TotalAmount: 725.74, Capital: 493.92, Interest: 231.82, RemainingCapital: 74691.19},
|
||||
{Rank: 124, DueDate: "2033-12-05", TotalAmount: 725.74, Capital: 495.44, Interest: 230.3, RemainingCapital: 74195.75},
|
||||
{Rank: 125, DueDate: "2034-01-05", TotalAmount: 725.74, Capital: 496.97, Interest: 228.77, RemainingCapital: 73698.78},
|
||||
{Rank: 126, DueDate: "2034-02-05", TotalAmount: 725.74, Capital: 498.5, Interest: 227.24, RemainingCapital: 73200.28},
|
||||
{Rank: 127, DueDate: "2034-03-05", TotalAmount: 725.74, Capital: 500.04, Interest: 225.7, RemainingCapital: 72700.24},
|
||||
{Rank: 128, DueDate: "2034-04-05", TotalAmount: 725.74, Capital: 501.58, Interest: 224.16, RemainingCapital: 72198.66},
|
||||
{Rank: 129, DueDate: "2034-05-05", TotalAmount: 725.74, Capital: 503.13, Interest: 222.61, RemainingCapital: 71695.53},
|
||||
{Rank: 130, DueDate: "2034-06-05", TotalAmount: 725.74, Capital: 504.68, Interest: 221.06, RemainingCapital: 71190.85},
|
||||
{Rank: 131, DueDate: "2034-07-05", TotalAmount: 725.74, Capital: 506.23, Interest: 219.51, RemainingCapital: 70684.62},
|
||||
{Rank: 132, DueDate: "2034-08-05", TotalAmount: 725.74, Capital: 507.8, Interest: 217.94, RemainingCapital: 70176.82},
|
||||
{Rank: 137, DueDate: "2035-01-05", TotalAmount: 725.74, Capital: 515.67, Interest: 210.07, RemainingCapital: 67614.26},
|
||||
{Rank: 138, DueDate: "2035-02-05", TotalAmount: 725.74, Capital: 517.26, Interest: 208.48, RemainingCapital: 67097.0},
|
||||
{Rank: 139, DueDate: "2035-03-05", TotalAmount: 725.74, Capital: 518.86, Interest: 206.88, RemainingCapital: 66578.14},
|
||||
{Rank: 140, DueDate: "2035-04-05", TotalAmount: 725.74, Capital: 520.46, Interest: 205.28, RemainingCapital: 66057.68},
|
||||
{Rank: 141, DueDate: "2035-05-05", TotalAmount: 725.74, Capital: 522.06, Interest: 203.68, RemainingCapital: 65535.62},
|
||||
{Rank: 142, DueDate: "2035-06-05", TotalAmount: 725.74, Capital: 523.67, Interest: 202.07, RemainingCapital: 65011.95},
|
||||
{Rank: 143, DueDate: "2035-07-05", TotalAmount: 725.74, Capital: 525.29, Interest: 200.45, RemainingCapital: 64486.66},
|
||||
{Rank: 144, DueDate: "2035-08-05", TotalAmount: 725.74, Capital: 526.91, Interest: 198.83, RemainingCapital: 63959.75},
|
||||
{Rank: 145, DueDate: "2035-09-05", TotalAmount: 725.74, Capital: 528.53, Interest: 197.21, RemainingCapital: 63431.22},
|
||||
{Rank: 146, DueDate: "2035-10-05", TotalAmount: 725.74, Capital: 530.16, Interest: 195.58, RemainingCapital: 62901.06},
|
||||
{Rank: 147, DueDate: "2035-11-05", TotalAmount: 725.74, Capital: 531.8, Interest: 193.94, RemainingCapital: 62369.26},
|
||||
{Rank: 148, DueDate: "2035-12-05", TotalAmount: 725.74, Capital: 533.43, Interest: 192.31, RemainingCapital: 61835.83},
|
||||
{Rank: 149, DueDate: "2036-01-05", TotalAmount: 725.74, Capital: 535.08, Interest: 190.66, RemainingCapital: 61300.75},
|
||||
{Rank: 150, DueDate: "2036-02-05", TotalAmount: 725.74, Capital: 536.73, Interest: 189.01, RemainingCapital: 60764.02},
|
||||
{Rank: 151, DueDate: "2036-03-05", TotalAmount: 725.74, Capital: 538.38, Interest: 187.36, RemainingCapital: 60225.64},
|
||||
{Rank: 152, DueDate: "2036-04-05", TotalAmount: 725.74, Capital: 540.04, Interest: 185.7, RemainingCapital: 59685.6},
|
||||
{Rank: 153, DueDate: "2036-05-05", TotalAmount: 725.74, Capital: 541.71, Interest: 184.03, RemainingCapital: 59143.89},
|
||||
{Rank: 154, DueDate: "2036-06-05", TotalAmount: 725.74, Capital: 543.38, Interest: 182.36, RemainingCapital: 58600.51},
|
||||
{Rank: 155, DueDate: "2036-07-05", TotalAmount: 725.74, Capital: 545.06, Interest: 180.68, RemainingCapital: 58055.45},
|
||||
{Rank: 156, DueDate: "2036-08-05", TotalAmount: 725.74, Capital: 546.74, Interest: 179.0, RemainingCapital: 57508.71},
|
||||
{Rank: 157, DueDate: "2036-09-05", TotalAmount: 725.74, Capital: 548.42, Interest: 177.32, RemainingCapital: 56960.29},
|
||||
{Rank: 158, DueDate: "2036-10-05", TotalAmount: 725.74, Capital: 550.11, Interest: 175.63, RemainingCapital: 56410.18},
|
||||
{Rank: 159, DueDate: "2036-11-05", TotalAmount: 725.74, Capital: 551.81, Interest: 173.93, RemainingCapital: 55858.37},
|
||||
{Rank: 160, DueDate: "2036-12-05", TotalAmount: 725.74, Capital: 553.51, Interest: 172.23, RemainingCapital: 55304.86},
|
||||
{Rank: 161, DueDate: "2037-01-05", TotalAmount: 725.74, Capital: 555.22, Interest: 170.52, RemainingCapital: 54749.64},
|
||||
{Rank: 162, DueDate: "2037-02-05", TotalAmount: 725.74, Capital: 556.93, Interest: 168.81, RemainingCapital: 54192.71},
|
||||
{Rank: 163, DueDate: "2037-03-05", TotalAmount: 725.74, Capital: 558.65, Interest: 167.09, RemainingCapital: 53634.06},
|
||||
{Rank: 164, DueDate: "2037-04-05", TotalAmount: 725.74, Capital: 560.37, Interest: 165.37, RemainingCapital: 53073.69},
|
||||
{Rank: 165, DueDate: "2037-05-05", TotalAmount: 725.74, Capital: 562.1, Interest: 163.64, RemainingCapital: 52511.59},
|
||||
{Rank: 166, DueDate: "2037-06-05", TotalAmount: 725.74, Capital: 563.83, Interest: 161.91, RemainingCapital: 51947.76},
|
||||
{Rank: 167, DueDate: "2037-07-05", TotalAmount: 725.74, Capital: 565.57, Interest: 160.17, RemainingCapital: 51382.19},
|
||||
{Rank: 168, DueDate: "2037-08-05", TotalAmount: 725.74, Capital: 567.31, Interest: 158.43, RemainingCapital: 50814.88},
|
||||
{Rank: 169, DueDate: "2037-09-05", TotalAmount: 725.74, Capital: 569.06, Interest: 156.68, RemainingCapital: 50245.82},
|
||||
{Rank: 170, DueDate: "2037-10-05", TotalAmount: 725.74, Capital: 570.82, Interest: 154.92, RemainingCapital: 49675.0},
|
||||
{Rank: 171, DueDate: "2037-11-05", TotalAmount: 725.74, Capital: 572.58, Interest: 153.16, RemainingCapital: 49102.42},
|
||||
{Rank: 172, DueDate: "2037-12-05", TotalAmount: 725.74, Capital: 574.34, Interest: 151.4, RemainingCapital: 48528.08},
|
||||
{Rank: 173, DueDate: "2038-01-05", TotalAmount: 725.74, Capital: 576.11, Interest: 149.63, RemainingCapital: 47951.97},
|
||||
{Rank: 174, DueDate: "2038-02-05", TotalAmount: 725.74, Capital: 577.89, Interest: 147.85, RemainingCapital: 47374.08},
|
||||
{Rank: 175, DueDate: "2038-03-05", TotalAmount: 725.74, Capital: 579.67, Interest: 146.07, RemainingCapital: 46794.41},
|
||||
{Rank: 176, DueDate: "2038-04-05", TotalAmount: 725.74, Capital: 581.46, Interest: 144.28, RemainingCapital: 46212.95},
|
||||
{Rank: 177, DueDate: "2038-05-05", TotalAmount: 725.74, Capital: 583.25, Interest: 142.49, RemainingCapital: 45629.7},
|
||||
{Rank: 178, DueDate: "2038-06-05", TotalAmount: 725.74, Capital: 585.05, Interest: 140.69, RemainingCapital: 45044.65},
|
||||
{Rank: 179, DueDate: "2038-07-05", TotalAmount: 725.74, Capital: 586.85, Interest: 138.89, RemainingCapital: 44457.8},
|
||||
{Rank: 180, DueDate: "2038-08-05", TotalAmount: 725.74, Capital: 588.66, Interest: 137.08, RemainingCapital: 43869.14},
|
||||
{Rank: 181, DueDate: "2038-09-05", TotalAmount: 725.74, Capital: 590.48, Interest: 135.26, RemainingCapital: 43278.66},
|
||||
{Rank: 182, DueDate: "2038-10-05", TotalAmount: 725.74, Capital: 592.3, Interest: 133.44, RemainingCapital: 42686.36},
|
||||
{Rank: 183, DueDate: "2038-11-05", TotalAmount: 725.74, Capital: 594.12, Interest: 131.62, RemainingCapital: 42092.24},
|
||||
{Rank: 184, DueDate: "2038-12-05", TotalAmount: 725.74, Capital: 595.96, Interest: 129.78, RemainingCapital: 41496.28},
|
||||
{Rank: 185, DueDate: "2039-01-05", TotalAmount: 725.74, Capital: 597.79, Interest: 127.95, RemainingCapital: 40898.49},
|
||||
{Rank: 186, DueDate: "2039-02-05", TotalAmount: 725.74, Capital: 599.64, Interest: 126.1, RemainingCapital: 40298.85},
|
||||
{Rank: 187, DueDate: "2039-03-05", TotalAmount: 725.74, Capital: 601.49, Interest: 124.25, RemainingCapital: 39697.36},
|
||||
{Rank: 188, DueDate: "2039-04-05", TotalAmount: 725.74, Capital: 603.34, Interest: 122.4, RemainingCapital: 39094.02},
|
||||
{Rank: 189, DueDate: "2039-05-05", TotalAmount: 725.74, Capital: 605.2, Interest: 120.54, RemainingCapital: 38488.82},
|
||||
{Rank: 190, DueDate: "2039-06-05", TotalAmount: 725.74, Capital: 607.07, Interest: 118.67, RemainingCapital: 37881.75},
|
||||
{Rank: 191, DueDate: "2039-07-05", TotalAmount: 725.74, Capital: 608.94, Interest: 116.8, RemainingCapital: 37272.81},
|
||||
{Rank: 192, DueDate: "2039-08-05", TotalAmount: 725.74, Capital: 610.82, Interest: 114.92, RemainingCapital: 36661.99},
|
||||
{Rank: 193, DueDate: "2039-09-05", TotalAmount: 725.74, Capital: 612.7, Interest: 113.04, RemainingCapital: 36049.29},
|
||||
{Rank: 194, DueDate: "2039-10-05", TotalAmount: 725.74, Capital: 614.59, Interest: 111.15, RemainingCapital: 35434.7},
|
||||
{Rank: 195, DueDate: "2039-11-05", TotalAmount: 725.74, Capital: 616.48, Interest: 109.26, RemainingCapital: 34818.22},
|
||||
{Rank: 196, DueDate: "2039-12-05", TotalAmount: 725.74, Capital: 618.38, Interest: 107.36, RemainingCapital: 34199.84},
|
||||
{Rank: 197, DueDate: "2040-01-05", TotalAmount: 725.74, Capital: 620.29, Interest: 105.45, RemainingCapital: 33579.55},
|
||||
{Rank: 198, DueDate: "2040-02-05", TotalAmount: 725.74, Capital: 622.2, Interest: 103.54, RemainingCapital: 32957.35},
|
||||
{Rank: 199, DueDate: "2040-03-05", TotalAmount: 725.74, Capital: 624.12, Interest: 101.62, RemainingCapital: 32333.23},
|
||||
{Rank: 200, DueDate: "2040-04-05", TotalAmount: 725.74, Capital: 626.05, Interest: 99.69, RemainingCapital: 31707.18},
|
||||
{Rank: 201, DueDate: "2040-05-05", TotalAmount: 725.74, Capital: 627.98, Interest: 97.76, RemainingCapital: 31079.2},
|
||||
{Rank: 202, DueDate: "2040-06-05", TotalAmount: 725.74, Capital: 629.91, Interest: 95.83, RemainingCapital: 30449.29},
|
||||
{Rank: 203, DueDate: "2040-07-05", TotalAmount: 725.74, Capital: 631.85, Interest: 93.89, RemainingCapital: 29817.44},
|
||||
{Rank: 204, DueDate: "2040-08-05", TotalAmount: 725.74, Capital: 633.8, Interest: 91.94, RemainingCapital: 29183.64},
|
||||
{Rank: 205, DueDate: "2040-09-05", TotalAmount: 725.74, Capital: 635.76, Interest: 89.98, RemainingCapital: 28547.88},
|
||||
{Rank: 206, DueDate: "2040-10-05", TotalAmount: 725.74, Capital: 637.72, Interest: 88.02, RemainingCapital: 27910.16},
|
||||
{Rank: 207, DueDate: "2040-11-05", TotalAmount: 725.74, Capital: 639.68, Interest: 86.06, RemainingCapital: 27270.48},
|
||||
{Rank: 208, DueDate: "2040-12-05", TotalAmount: 725.74, Capital: 641.66, Interest: 84.08, RemainingCapital: 26628.82},
|
||||
{Rank: 209, DueDate: "2041-01-05", TotalAmount: 725.74, Capital: 643.63, Interest: 82.11, RemainingCapital: 25985.19},
|
||||
{Rank: 210, DueDate: "2041-02-05", TotalAmount: 725.74, Capital: 645.62, Interest: 80.12, RemainingCapital: 25339.57},
|
||||
{Rank: 211, DueDate: "2041-03-05", TotalAmount: 725.74, Capital: 647.61, Interest: 78.13, RemainingCapital: 24691.96},
|
||||
{Rank: 212, DueDate: "2041-04-05", TotalAmount: 725.74, Capital: 649.61, Interest: 76.13, RemainingCapital: 24042.35},
|
||||
{Rank: 213, DueDate: "2041-05-05", TotalAmount: 725.74, Capital: 651.61, Interest: 74.13, RemainingCapital: 23390.74},
|
||||
{Rank: 214, DueDate: "2041-06-05", TotalAmount: 725.74, Capital: 653.62, Interest: 72.12, RemainingCapital: 22737.12},
|
||||
{Rank: 215, DueDate: "2041-07-05", TotalAmount: 725.74, Capital: 655.63, Interest: 70.11, RemainingCapital: 22081.49},
|
||||
{Rank: 216, DueDate: "2041-08-05", TotalAmount: 725.74, Capital: 657.66, Interest: 68.08, RemainingCapital: 21423.83},
|
||||
{Rank: 217, DueDate: "2041-09-05", TotalAmount: 725.74, Capital: 659.68, Interest: 66.06, RemainingCapital: 20764.15},
|
||||
{Rank: 218, DueDate: "2041-10-05", TotalAmount: 725.74, Capital: 661.72, Interest: 64.02, RemainingCapital: 20102.43},
|
||||
{Rank: 219, DueDate: "2041-11-05", TotalAmount: 725.74, Capital: 663.76, Interest: 61.98, RemainingCapital: 19438.67},
|
||||
{Rank: 220, DueDate: "2041-12-05", TotalAmount: 725.74, Capital: 665.8, Interest: 59.94, RemainingCapital: 18772.87},
|
||||
{Rank: 221, DueDate: "2042-01-05", TotalAmount: 725.74, Capital: 667.86, Interest: 57.88, RemainingCapital: 18105.01},
|
||||
{Rank: 222, DueDate: "2042-02-05", TotalAmount: 725.74, Capital: 669.92, Interest: 55.82, RemainingCapital: 17435.09},
|
||||
{Rank: 223, DueDate: "2042-03-05", TotalAmount: 725.74, Capital: 671.98, Interest: 53.76, RemainingCapital: 16763.11},
|
||||
{Rank: 224, DueDate: "2042-04-05", TotalAmount: 725.74, Capital: 674.05, Interest: 51.69, RemainingCapital: 16089.06},
|
||||
{Rank: 225, DueDate: "2042-05-05", TotalAmount: 725.74, Capital: 676.13, Interest: 49.61, RemainingCapital: 15412.93},
|
||||
{Rank: 226, DueDate: "2042-06-05", TotalAmount: 725.74, Capital: 678.22, Interest: 47.52, RemainingCapital: 14734.71},
|
||||
{Rank: 227, DueDate: "2042-07-05", TotalAmount: 725.74, Capital: 680.31, Interest: 45.43, RemainingCapital: 14054.4},
|
||||
{Rank: 228, DueDate: "2042-08-05", TotalAmount: 725.74, Capital: 682.41, Interest: 43.33, RemainingCapital: 13371.99},
|
||||
{Rank: 229, DueDate: "2042-09-05", TotalAmount: 725.74, Capital: 684.51, Interest: 41.23, RemainingCapital: 12687.48},
|
||||
{Rank: 230, DueDate: "2042-10-05", TotalAmount: 725.74, Capital: 686.62, Interest: 39.12, RemainingCapital: 12000.86},
|
||||
{Rank: 231, DueDate: "2042-11-05", TotalAmount: 725.74, Capital: 688.74, Interest: 37.0, RemainingCapital: 11312.12},
|
||||
{Rank: 232, DueDate: "2042-12-05", TotalAmount: 725.74, Capital: 690.86, Interest: 34.88, RemainingCapital: 10621.26},
|
||||
{Rank: 233, DueDate: "2043-01-05", TotalAmount: 725.74, Capital: 692.99, Interest: 32.75, RemainingCapital: 9928.27},
|
||||
{Rank: 234, DueDate: "2043-02-05", TotalAmount: 725.74, Capital: 695.13, Interest: 30.61, RemainingCapital: 9233.14},
|
||||
{Rank: 235, DueDate: "2043-03-05", TotalAmount: 725.74, Capital: 697.27, Interest: 28.47, RemainingCapital: 8535.87},
|
||||
{Rank: 236, DueDate: "2043-04-05", TotalAmount: 725.74, Capital: 699.42, Interest: 26.32, RemainingCapital: 7836.45},
|
||||
{Rank: 237, DueDate: "2043-05-05", TotalAmount: 725.74, Capital: 701.58, Interest: 24.16, RemainingCapital: 7134.87},
|
||||
{Rank: 238, DueDate: "2043-06-05", TotalAmount: 725.74, Capital: 703.74, Interest: 22.0, RemainingCapital: 6431.13},
|
||||
{Rank: 239, DueDate: "2043-07-05", TotalAmount: 725.74, Capital: 705.91, Interest: 19.83, RemainingCapital: 5725.22},
|
||||
{Rank: 240, DueDate: "2043-08-05", TotalAmount: 725.74, Capital: 708.09, Interest: 17.65, RemainingCapital: 5017.13},
|
||||
{Rank: 241, DueDate: "2043-09-05", TotalAmount: 725.74, Capital: 710.27, Interest: 15.47, RemainingCapital: 4306.86},
|
||||
{Rank: 242, DueDate: "2043-10-05", TotalAmount: 725.74, Capital: 712.46, Interest: 13.28, RemainingCapital: 3594.4},
|
||||
{Rank: 243, DueDate: "2043-11-05", TotalAmount: 725.74, Capital: 714.66, Interest: 11.08, RemainingCapital: 2879.74},
|
||||
{Rank: 244, DueDate: "2043-12-05", TotalAmount: 725.74, Capital: 716.86, Interest: 8.88, RemainingCapital: 2162.88},
|
||||
{Rank: 245, DueDate: "2044-01-05", TotalAmount: 725.74, Capital: 719.07, Interest: 6.67, RemainingCapital: 1443.81},
|
||||
{Rank: 246, DueDate: "2044-02-05", TotalAmount: 725.74, Capital: 721.29, Interest: 4.45, RemainingCapital: 722.52},
|
||||
{Rank: 247, DueDate: "2044-03-05", TotalAmount: 725.74, Capital: 722.52, Interest: 3.22, RemainingCapital: 0.0},
|
||||
}
|
||||
}
|
||||
166
internal/property/property.go
Normal file
166
internal/property/property.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package property
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Property struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Type string `json:"type"` // airbnb | longterm
|
||||
BankAccount string `json:"bank_account"`
|
||||
IcalURL string `json:"ical_url"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) List() ([]Property, error) {
|
||||
rows, err := s.db.Query(`SELECT id, name, address, type, COALESCE(bank_account,''), COALESCE(ical_url,''), COALESCE(notes,''), created_at FROM properties ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var props []Property
|
||||
for rows.Next() {
|
||||
var p Property
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Address, &p.Type, &p.BankAccount, &p.IcalURL, &p.Notes, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
props = append(props, p)
|
||||
}
|
||||
return props, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Property, error) {
|
||||
var p Property
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, address, type, COALESCE(bank_account,''), COALESCE(ical_url,''), COALESCE(notes,''), created_at FROM properties WHERE id=?`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.Address, &p.Type, &p.BankAccount, &p.IcalURL, &p.Notes, &p.CreatedAt)
|
||||
return &p, err
|
||||
}
|
||||
|
||||
func (s *Store) Create(p *Property) error {
|
||||
p.ID = uuid.NewString()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO properties (id, name, address, type, bank_account, ical_url, notes) VALUES (?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Name, p.Address, p.Type, p.BankAccount, p.IcalURL, p.Notes,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Update(p *Property) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE properties SET name=?, address=?, type=?, bank_account=?, ical_url=?, notes=? WHERE id=?`,
|
||||
p.Name, p.Address, p.Type, p.BankAccount, p.IcalURL, p.Notes, p.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM properties WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListWithIcal() ([]Property, error) {
|
||||
rows, err := s.db.Query(`SELECT id, name, address, type, COALESCE(bank_account,''), COALESCE(ical_url,''), COALESCE(notes,''), created_at FROM properties WHERE ical_url IS NOT NULL AND ical_url != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var props []Property
|
||||
for rows.Next() {
|
||||
var p Property
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Address, &p.Type, &p.BankAccount, &p.IcalURL, &p.Notes, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
props = append(props, p)
|
||||
}
|
||||
return props, nil
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler struct{ store *Store }
|
||||
|
||||
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
props, err := h.store.List()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if props == nil {
|
||||
props = []Property{}
|
||||
}
|
||||
respond(w, props)
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
p, err := h.store.Get(id)
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, p)
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var p Property
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.Create(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respond(w, p)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var p Property
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.ID = mux.Vars(r)["id"]
|
||||
if err := h.store.Update(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, p)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
483
internal/transaction/transaction.go
Normal file
483
internal/transaction/transaction.go
Normal file
@@ -0,0 +1,483 @@
|
||||
package transaction
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Transaction struct {
|
||||
ID string `json:"id"`
|
||||
PropertyID string `json:"property_id"`
|
||||
CategoryID string `json:"category_id"`
|
||||
Type string `json:"type"` // income | expense
|
||||
Amount float64 `json:"amount"`
|
||||
Date string `json:"date"`
|
||||
Description string `json:"description"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Champs joints
|
||||
CategoryName string `json:"category_name,omitempty"`
|
||||
PropertyName string `json:"property_name,omitempty"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
PropertyID string `json:"property_id"`
|
||||
PropertyName string `json:"property_name"`
|
||||
Year int `json:"year"`
|
||||
TotalIncome float64 `json:"total_income"`
|
||||
TotalExpense float64 `json:"total_expense"`
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) List(propertyID, txType, year, month string) ([]Transaction, error) {
|
||||
query := `
|
||||
SELECT t.id, t.property_id, COALESCE(t.category_id,''), t.type, t.amount, strftime('%Y-%m-%d', t.date) as date,
|
||||
COALESCE(t.description,''), COALESCE(t.created_by,''), t.created_at,
|
||||
COALESCE(c.name,''), p.name
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN properties p ON p.id = t.property_id
|
||||
WHERE 1=1`
|
||||
args := []any{}
|
||||
if propertyID != "" {
|
||||
query += " AND t.property_id = ?"
|
||||
args = append(args, propertyID)
|
||||
}
|
||||
if txType != "" {
|
||||
query += " AND t.type = ?"
|
||||
args = append(args, txType)
|
||||
}
|
||||
if year != "" {
|
||||
query += " AND strftime('%Y', t.date) = ?"
|
||||
args = append(args, year)
|
||||
}
|
||||
if month != "" {
|
||||
query += " AND strftime('%m', t.date) = ?"
|
||||
args = append(args, fmt.Sprintf("%02s", month))
|
||||
}
|
||||
query += " ORDER BY t.date DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var txs []Transaction
|
||||
for rows.Next() {
|
||||
var t Transaction
|
||||
if err := rows.Scan(&t.ID, &t.PropertyID, &t.CategoryID, &t.Type, &t.Amount,
|
||||
&t.Date, &t.Description, &t.CreatedBy, &t.CreatedAt, &t.CategoryName, &t.PropertyName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
txs = append(txs, t)
|
||||
}
|
||||
return txs, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Transaction, error) {
|
||||
var t Transaction
|
||||
err := s.db.QueryRow(`
|
||||
SELECT t.id, t.property_id, COALESCE(t.category_id,''), t.type, t.amount, strftime('%Y-%m-%d', t.date) as date,
|
||||
COALESCE(t.description,''), COALESCE(t.created_by,''), t.created_at,
|
||||
COALESCE(c.name,''), p.name
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN properties p ON p.id = t.property_id
|
||||
WHERE t.id=?`, id,
|
||||
).Scan(&t.ID, &t.PropertyID, &t.CategoryID, &t.Type, &t.Amount,
|
||||
&t.Date, &t.Description, &t.CreatedBy, &t.CreatedAt, &t.CategoryName, &t.PropertyName)
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (s *Store) Create(t *Transaction) error {
|
||||
t.ID = uuid.NewString()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO transactions (id, property_id, category_id, type, amount, date, description, created_by) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.PropertyID, nullStr(t.CategoryID), t.Type, t.Amount, t.Date, t.Description, nullStr(t.CreatedBy),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Update(t *Transaction) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE transactions SET property_id=?, category_id=?, type=?, amount=?, date=?, description=? WHERE id=?`,
|
||||
t.PropertyID, nullStr(t.CategoryID), t.Type, t.Amount, t.Date, t.Description, t.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM transactions WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetSummary(propertyID, year, month string) ([]Summary, error) {
|
||||
query := `
|
||||
SELECT t.property_id, p.name,
|
||||
strftime('%Y', t.date) as yr,
|
||||
SUM(CASE WHEN t.type='income' THEN t.amount ELSE 0 END) as income,
|
||||
SUM(CASE WHEN t.type='expense' THEN t.amount ELSE 0 END) as expense
|
||||
FROM transactions t
|
||||
JOIN properties p ON p.id = t.property_id
|
||||
WHERE 1=1`
|
||||
args := []any{}
|
||||
if propertyID != "" {
|
||||
query += " AND t.property_id=?"
|
||||
args = append(args, propertyID)
|
||||
}
|
||||
if year != "" {
|
||||
query += " AND strftime('%Y', t.date)=?"
|
||||
args = append(args, year)
|
||||
}
|
||||
if month != "" {
|
||||
query += " AND strftime('%m', t.date)=?"
|
||||
args = append(args, fmt.Sprintf("%02s", month))
|
||||
}
|
||||
query += " GROUP BY t.property_id, yr ORDER BY yr DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var summaries []Summary
|
||||
for rows.Next() {
|
||||
var s Summary
|
||||
var yr string
|
||||
if err := rows.Scan(&s.PropertyID, &s.PropertyName, &yr, &s.TotalIncome, &s.TotalExpense); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Balance = s.TotalIncome - s.TotalExpense
|
||||
summaries = append(summaries, s)
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetByPropertyAndYear(propertyID string, year int) ([]Transaction, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT t.id, t.property_id, COALESCE(t.category_id,''), t.type, t.amount, strftime('%Y-%m-%d', t.date) as date,
|
||||
COALESCE(t.description,''), COALESCE(t.created_by,''), t.created_at,
|
||||
COALESCE(c.name,''), p.name
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN properties p ON p.id = t.property_id
|
||||
WHERE t.property_id=? AND strftime('%Y', t.date)=?
|
||||
ORDER BY t.date`,
|
||||
propertyID, year,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var txs []Transaction
|
||||
for rows.Next() {
|
||||
var t Transaction
|
||||
if err := rows.Scan(&t.ID, &t.PropertyID, &t.CategoryID, &t.Type, &t.Amount,
|
||||
&t.Date, &t.Description, &t.CreatedBy, &t.CreatedAt, &t.CategoryName, &t.PropertyName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
txs = append(txs, t)
|
||||
}
|
||||
return txs, nil
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler struct{ store *Store }
|
||||
|
||||
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
txs, err := h.store.List(q.Get("property_id"), q.Get("type"), q.Get("year"), q.Get("month"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if txs == nil {
|
||||
txs = []Transaction{}
|
||||
}
|
||||
respond(w, txs)
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := h.store.Get(mux.Vars(r)["id"])
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, t)
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var t Transaction
|
||||
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.Create(&t); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respond(w, t)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var t Transaction
|
||||
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
t.ID = mux.Vars(r)["id"]
|
||||
if err := h.store.Update(&t); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, t)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
summaries, err := h.store.GetSummary(q.Get("property_id"), q.Get("year"), q.Get("month"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if summaries == nil {
|
||||
summaries = []Summary{}
|
||||
}
|
||||
respond(w, summaries)
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Données mensuelles pour graphiques ───────────────────────────────────────
|
||||
|
||||
type MonthlyData struct {
|
||||
Month string `json:"month"` // "2026-01"
|
||||
Income float64 `json:"income"`
|
||||
Expense float64 `json:"expense"`
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
|
||||
type CategoryBreakdown struct {
|
||||
Category string `json:"category"`
|
||||
Amount float64 `json:"amount"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (s *Store) GetMonthlyData(propertyID, year string) ([]MonthlyData, error) {
|
||||
query := `
|
||||
SELECT strftime('%Y-%m', t.date) as month,
|
||||
SUM(CASE WHEN t.type='income' THEN t.amount ELSE 0 END) as income,
|
||||
SUM(CASE WHEN t.type='expense' THEN t.amount ELSE 0 END) as expense
|
||||
FROM transactions t
|
||||
WHERE strftime('%Y', t.date) = ?`
|
||||
args := []any{year}
|
||||
if propertyID != "" {
|
||||
query += " AND t.property_id = ?"
|
||||
args = append(args, propertyID)
|
||||
}
|
||||
query += " GROUP BY month ORDER BY month"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Pré-remplir les 12 mois avec zéros
|
||||
data := make(map[string]*MonthlyData)
|
||||
for m := 1; m <= 12; m++ {
|
||||
key := fmt.Sprintf("%s-%02d", year, m)
|
||||
data[key] = &MonthlyData{Month: key}
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var d MonthlyData
|
||||
if err := rows.Scan(&d.Month, &d.Income, &d.Expense); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Balance = d.Income - d.Expense
|
||||
data[d.Month] = &d
|
||||
}
|
||||
|
||||
// Retourner dans l'ordre
|
||||
result := make([]MonthlyData, 12)
|
||||
for m := 1; m <= 12; m++ {
|
||||
key := fmt.Sprintf("%s-%02d", year, m)
|
||||
result[m-1] = *data[key]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetCategoryBreakdown(propertyID, year, month, txType string) ([]CategoryBreakdown, error) {
|
||||
query := `
|
||||
SELECT COALESCE(c.name, 'Sans catégorie') as category,
|
||||
SUM(t.amount) as amount,
|
||||
t.type
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
WHERE strftime('%Y', t.date) = ?`
|
||||
args := []any{year}
|
||||
if propertyID != "" {
|
||||
query += " AND t.property_id = ?"
|
||||
args = append(args, propertyID)
|
||||
}
|
||||
if month != "" {
|
||||
query += " AND strftime('%m', t.date) = ?"
|
||||
args = append(args, fmt.Sprintf("%02s", month))
|
||||
}
|
||||
if txType != "" {
|
||||
query += " AND t.type = ?"
|
||||
args = append(args, txType)
|
||||
}
|
||||
query += " GROUP BY category, t.type ORDER BY amount DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []CategoryBreakdown
|
||||
for rows.Next() {
|
||||
var d CategoryBreakdown
|
||||
if err := rows.Scan(&d.Category, &d.Amount, &d.Type); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Monthly(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
year := q.Get("year")
|
||||
if year == "" {
|
||||
year = fmt.Sprintf("%d", time.Now().Year())
|
||||
}
|
||||
data, err := h.store.GetMonthlyData(q.Get("property_id"), year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, data)
|
||||
}
|
||||
|
||||
func (h *Handler) CategoryBreakdown(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
year := q.Get("year")
|
||||
if year == "" {
|
||||
year = fmt.Sprintf("%d", time.Now().Year())
|
||||
}
|
||||
data, err := h.store.GetCategoryBreakdown(q.Get("property_id"), year, q.Get("month"), q.Get("type"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if data == nil {
|
||||
data = []CategoryBreakdown{}
|
||||
}
|
||||
respond(w, data)
|
||||
}
|
||||
|
||||
// ── Split transaction ─────────────────────────────────────────────────────────
|
||||
|
||||
type SplitRequest struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Splits []Split `json:"splits"`
|
||||
}
|
||||
|
||||
type Split struct {
|
||||
PropertyID string `json:"property_id"`
|
||||
CategoryID string `json:"category_id"`
|
||||
Type string `json:"type"` // income | expense — si vide, hérite du type source
|
||||
Amount float64 `json:"amount"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (h *Handler) SplitTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
var req SplitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Splits) < 2 {
|
||||
http.Error(w, "au moins 2 parts requises", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la transaction source
|
||||
source, err := h.store.Get(req.SourceID)
|
||||
if err != nil {
|
||||
http.Error(w, "transaction source introuvable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Créer les nouvelles transactions
|
||||
created := []Transaction{}
|
||||
for _, s := range req.Splits {
|
||||
txType := source.Type
|
||||
if s.Type == "income" || s.Type == "expense" {
|
||||
txType = s.Type
|
||||
}
|
||||
t := &Transaction{
|
||||
PropertyID: s.PropertyID,
|
||||
CategoryID: source.CategoryID,
|
||||
Type: txType,
|
||||
Amount: s.Amount,
|
||||
Date: source.Date,
|
||||
Description: s.Description,
|
||||
}
|
||||
if s.CategoryID != "" {
|
||||
t.CategoryID = s.CategoryID
|
||||
}
|
||||
if err := h.store.Create(t); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
created = append(created, *t)
|
||||
}
|
||||
|
||||
// Supprimer la transaction source
|
||||
h.store.Delete(req.SourceID)
|
||||
|
||||
respond(w, created)
|
||||
}
|
||||
Reference in New Issue
Block a user