From af0d98683c4ec10a2fed8379be2f182e96166250 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 12:47:58 +0200 Subject: [PATCH 1/3] clean taux info Signed-off-by: GnomeZworc --- internal/handler/account.go | 113 +++++++++++++++++++ internal/server/server.go | 1 + internal/store/account.go | 69 +++++++++++ migrations/000003_drop_account_taux.down.sql | 1 + migrations/000003_drop_account_taux.up.sql | 3 + 5 files changed, 187 insertions(+) create mode 100644 internal/handler/account.go create mode 100644 internal/store/account.go create mode 100644 migrations/000003_drop_account_taux.down.sql create mode 100644 migrations/000003_drop_account_taux.up.sql diff --git a/internal/handler/account.go b/internal/handler/account.go new file mode 100644 index 0000000..cdc33b4 --- /dev/null +++ b/internal/handler/account.go @@ -0,0 +1,113 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "git.g3e.fr/H6N/account/internal/store" + "github.com/jackc/pgx/v5" +) + +type AccountHandler struct { + store *store.Store +} + +func NewAccountHandler(s *store.Store) *AccountHandler { + return &AccountHandler{store: s} +} + +func (h *AccountHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /accounts", h.list) + mux.HandleFunc("POST /accounts", h.create) + mux.HandleFunc("GET /accounts/{id}", h.get) + mux.HandleFunc("PUT /accounts/{id}", h.update) + mux.HandleFunc("DELETE /accounts/{id}", h.delete) +} + +func (h *AccountHandler) list(w http.ResponseWriter, r *http.Request) { + accounts, err := h.store.ListAccounts(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list accounts") + return + } + writeJSON(w, http.StatusOK, accounts) +} + +func (h *AccountHandler) get(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + account, err := h.store.GetAccount(r.Context(), id) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "account not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get account") + return + } + writeJSON(w, http.StatusOK, account) +} + +func (h *AccountHandler) create(w http.ResponseWriter, r *http.Request) { + var p store.CreateAccountParams + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if p.Nom == "" || p.Type == "" { + writeError(w, http.StatusBadRequest, "nom and type are required") + return + } + if p.DeviseReference == "" { + p.DeviseReference = "EUR" + } + account, err := h.store.CreateAccount(r.Context(), p) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create account") + return + } + writeJSON(w, http.StatusCreated, account) +} + +func (h *AccountHandler) update(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var p store.CreateAccountParams + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if p.DeviseReference == "" { + p.DeviseReference = "EUR" + } + account, err := h.store.UpdateAccount(r.Context(), id, p) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "account not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update account") + return + } + writeJSON(w, http.StatusOK, account) +} + +func (h *AccountHandler) delete(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.store.DeleteAccount(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete account") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/server.go b/internal/server/server.go index fee7822..d53b3ed 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -37,6 +37,7 @@ func (s *Server) routes() { st := store.New(s.pool) handler.NewInstrumentHandler(st).RegisterRoutes(s.mux) + handler.NewAccountHandler(st).RegisterRoutes(s.mux) s.mux.HandleFunc("GET /health", s.handleHealth) } diff --git a/internal/store/account.go b/internal/store/account.go new file mode 100644 index 0000000..4f94760 --- /dev/null +++ b/internal/store/account.go @@ -0,0 +1,69 @@ +package store + +import ( + "context" + + "github.com/jackc/pgx/v5" +) + +type Account struct { + ID int32 `json:"id"` + Nom string `json:"nom"` + Type string `json:"type"` + DeviseReference string `json:"devise_reference"` + Plafond *float64 `json:"plafond,omitempty"` +} + +type CreateAccountParams struct { + Nom string `json:"nom"` + Type string `json:"type"` + DeviseReference string `json:"devise_reference"` + Plafond *float64 `json:"plafond,omitempty"` +} + +func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, nom, type, devise_reference, plafond FROM account ORDER BY nom`) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByName[Account]) +} + +func (s *Store) GetAccount(ctx context.Context, id int32) (Account, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, nom, type, devise_reference, plafond FROM account WHERE id = $1`, id) + if err != nil { + return Account{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) +} + +func (s *Store) CreateAccount(ctx context.Context, p CreateAccountParams) (Account, error) { + rows, err := s.pool.Query(ctx, + `INSERT INTO account (nom, type, devise_reference, plafond, taux) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, nom, type, devise_reference, plafond`, + p.Nom, p.Type, p.DeviseReference, p.Plafond) + if err != nil { + return Account{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) +} + +func (s *Store) UpdateAccount(ctx context.Context, id int32, p CreateAccountParams) (Account, error) { + rows, err := s.pool.Query(ctx, + `UPDATE account SET nom = $2, type = $3, devise_reference = $4, plafond = $5 + WHERE id = $1 + RETURNING id, nom, type, devise_reference, plafond`, + id, p.Nom, p.Type, p.DeviseReference, p.Plafond) + if err != nil { + return Account{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) +} + +func (s *Store) DeleteAccount(ctx context.Context, id int32) error { + _, err := s.pool.Exec(ctx, `DELETE FROM account WHERE id = $1`, id) + return err +} diff --git a/migrations/000003_drop_account_taux.down.sql b/migrations/000003_drop_account_taux.down.sql new file mode 100644 index 0000000..2fafcab --- /dev/null +++ b/migrations/000003_drop_account_taux.down.sql @@ -0,0 +1 @@ +ALTER TABLE account ADD COLUMN taux NUMERIC(10, 6); diff --git a/migrations/000003_drop_account_taux.up.sql b/migrations/000003_drop_account_taux.up.sql new file mode 100644 index 0000000..f322a8b --- /dev/null +++ b/migrations/000003_drop_account_taux.up.sql @@ -0,0 +1,3 @@ +-- taux retiré de account : le taux varie dans le temps et nécessite +-- une table account_rate(account_id, taux, date_debut, date_fin) dédiée +ALTER TABLE account DROP COLUMN taux; From 1f3ed998dfa00b03ab04d1c4a6d6ff0adedd259f Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 13:41:29 +0200 Subject: [PATCH 2/3] add envelop Signed-off-by: GnomeZworc --- internal/handler/account.go | 100 +++++++++++++++-- internal/store/account.go | 102 +++++++++++++++--- ...000004_envelopes_and_transactions.down.sql | 18 ++++ .../000004_envelopes_and_transactions.up.sql | 49 +++++++++ .../000005_envelope_as_account.down.sql | 59 ++++++++++ migrations/000005_envelope_as_account.up.sql | 50 +++++++++ .../000006_drop_montant_alloue.down.sql | 1 + migrations/000006_drop_montant_alloue.up.sql | 3 + 8 files changed, 363 insertions(+), 19 deletions(-) create mode 100644 migrations/000004_envelopes_and_transactions.down.sql create mode 100644 migrations/000004_envelopes_and_transactions.up.sql create mode 100644 migrations/000005_envelope_as_account.down.sql create mode 100644 migrations/000005_envelope_as_account.up.sql create mode 100644 migrations/000006_drop_montant_alloue.down.sql create mode 100644 migrations/000006_drop_montant_alloue.up.sql diff --git a/internal/handler/account.go b/internal/handler/account.go index cdc33b4..20eb0c5 100644 --- a/internal/handler/account.go +++ b/internal/handler/account.go @@ -18,13 +18,22 @@ func NewAccountHandler(s *store.Store) *AccountHandler { } func (h *AccountHandler) RegisterRoutes(mux *http.ServeMux) { + // Comptes maîtres mux.HandleFunc("GET /accounts", h.list) mux.HandleFunc("POST /accounts", h.create) mux.HandleFunc("GET /accounts/{id}", h.get) mux.HandleFunc("PUT /accounts/{id}", h.update) mux.HandleFunc("DELETE /accounts/{id}", h.delete) + + // Enveloppes (sous-comptes) + mux.HandleFunc("GET /accounts/{id}/envelopes", h.listEnvelopes) + mux.HandleFunc("POST /accounts/{id}/envelopes", h.createEnvelope) + mux.HandleFunc("PUT /envelopes/{id}", h.updateEnvelope) + mux.HandleFunc("DELETE /envelopes/{id}", h.deleteEnvelope) } +// --- Comptes maîtres --- + func (h *AccountHandler) list(w http.ResponseWriter, r *http.Request) { accounts, err := h.store.ListAccounts(r.Context()) if err != nil { @@ -40,7 +49,7 @@ func (h *AccountHandler) get(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid id") return } - account, err := h.store.GetAccount(r.Context(), id) + account, err := h.store.GetAccountWithEnvelopes(r.Context(), id) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "account not found") return @@ -62,9 +71,6 @@ func (h *AccountHandler) create(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "nom and type are required") return } - if p.DeviseReference == "" { - p.DeviseReference = "EUR" - } account, err := h.store.CreateAccount(r.Context(), p) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create account") @@ -84,9 +90,6 @@ func (h *AccountHandler) update(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid request body") return } - if p.DeviseReference == "" { - p.DeviseReference = "EUR" - } account, err := h.store.UpdateAccount(r.Context(), id, p) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "account not found") @@ -111,3 +114,86 @@ func (h *AccountHandler) delete(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +// --- Enveloppes --- + +func (h *AccountHandler) listEnvelopes(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + envelopes, err := h.store.ListEnvelopes(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list envelopes") + return + } + writeJSON(w, http.StatusOK, envelopes) +} + +func (h *AccountHandler) createEnvelope(w http.ResponseWriter, r *http.Request) { + masterID, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var p store.CreateEnvelopeParams + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if p.Nom == "" { + writeError(w, http.StatusBadRequest, "nom is required") + return + } + envelope, err := h.store.CreateEnvelope(r.Context(), masterID, p) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "account not found") + return + } + if errors.Is(err, store.ErrMasterIsSubAccount) { + writeError(w, http.StatusBadRequest, "account cannot itself be an envelope") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create envelope") + return + } + writeJSON(w, http.StatusCreated, envelope) +} + +func (h *AccountHandler) updateEnvelope(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var p store.CreateEnvelopeParams + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + envelope, err := h.store.UpdateEnvelope(r.Context(), id, p) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "envelope not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update envelope") + return + } + writeJSON(w, http.StatusOK, envelope) +} + +func (h *AccountHandler) deleteEnvelope(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.store.DeleteAccount(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete envelope") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/store/account.go b/internal/store/account.go index 4f94760..a00aaed 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -2,16 +2,19 @@ package store import ( "context" + "errors" "github.com/jackc/pgx/v5" ) type Account struct { - ID int32 `json:"id"` - Nom string `json:"nom"` - Type string `json:"type"` - DeviseReference string `json:"devise_reference"` + ID int32 `json:"id"` + Nom string `json:"nom"` + Type string `json:"type"` + DeviseReference string `json:"devise_reference"` Plafond *float64 `json:"plafond,omitempty"` + MasterAccountID *int32 `json:"master_account_id,omitempty"` + Objectif *string `json:"objectif,omitempty"` } type CreateAccountParams struct { @@ -21,9 +24,18 @@ type CreateAccountParams struct { Plafond *float64 `json:"plafond,omitempty"` } +type CreateEnvelopeParams struct { + Nom string `json:"nom"` + Objectif *string `json:"objectif,omitempty"` +} + +var ErrMasterIsSubAccount = errors.New("master account cannot itself be a sub-account") + +const accountCols = `id, nom, type, devise_reference, plafond, master_account_id, objectif` + func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { rows, err := s.pool.Query(ctx, - `SELECT id, nom, type, devise_reference, plafond FROM account ORDER BY nom`) + `SELECT `+accountCols+` FROM account WHERE master_account_id IS NULL ORDER BY nom`) if err != nil { return nil, err } @@ -32,18 +44,50 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { func (s *Store) GetAccount(ctx context.Context, id int32) (Account, error) { rows, err := s.pool.Query(ctx, - `SELECT id, nom, type, devise_reference, plafond FROM account WHERE id = $1`, id) + `SELECT `+accountCols+` FROM account WHERE id = $1`, id) if err != nil { return Account{}, err } return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) } -func (s *Store) CreateAccount(ctx context.Context, p CreateAccountParams) (Account, error) { +func (s *Store) ListEnvelopes(ctx context.Context, masterID int32) ([]Account, error) { rows, err := s.pool.Query(ctx, - `INSERT INTO account (nom, type, devise_reference, plafond, taux) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, nom, type, devise_reference, plafond`, + `SELECT `+accountCols+` FROM account WHERE master_account_id = $1 ORDER BY nom`, masterID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByName[Account]) +} + +type AccountWithEnvelopes struct { + Account + Envelopes []Account `json:"envelopes"` +} + +func (s *Store) GetAccountWithEnvelopes(ctx context.Context, id int32) (AccountWithEnvelopes, error) { + account, err := s.GetAccount(ctx, id) + if err != nil { + return AccountWithEnvelopes{}, err + } + envelopes, err := s.ListEnvelopes(ctx, id) + if err != nil { + return AccountWithEnvelopes{}, err + } + if envelopes == nil { + envelopes = []Account{} + } + return AccountWithEnvelopes{Account: account, Envelopes: envelopes}, nil +} + +func (s *Store) CreateAccount(ctx context.Context, p CreateAccountParams) (Account, error) { + if p.DeviseReference == "" { + p.DeviseReference = "EUR" + } + rows, err := s.pool.Query(ctx, + `INSERT INTO account (nom, type, devise_reference, plafond) + VALUES ($1, $2, $3, $4) + RETURNING `+accountCols, p.Nom, p.Type, p.DeviseReference, p.Plafond) if err != nil { return Account{}, err @@ -51,11 +95,33 @@ func (s *Store) CreateAccount(ctx context.Context, p CreateAccountParams) (Accou return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) } +func (s *Store) CreateEnvelope(ctx context.Context, masterID int32, p CreateEnvelopeParams) (Account, error) { + master, err := s.GetAccount(ctx, masterID) + if err != nil { + return Account{}, err + } + if master.MasterAccountID != nil { + return Account{}, ErrMasterIsSubAccount + } + rows, err := s.pool.Query(ctx, + `INSERT INTO account (nom, type, devise_reference, master_account_id, objectif) + VALUES ($1, $2, $3, $4, $5) + RETURNING `+accountCols, + p.Nom, master.Type, master.DeviseReference, masterID, p.Objectif) + if err != nil { + return Account{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) +} + func (s *Store) UpdateAccount(ctx context.Context, id int32, p CreateAccountParams) (Account, error) { + if p.DeviseReference == "" { + p.DeviseReference = "EUR" + } rows, err := s.pool.Query(ctx, `UPDATE account SET nom = $2, type = $3, devise_reference = $4, plafond = $5 - WHERE id = $1 - RETURNING id, nom, type, devise_reference, plafond`, + WHERE id = $1 AND master_account_id IS NULL + RETURNING `+accountCols, id, p.Nom, p.Type, p.DeviseReference, p.Plafond) if err != nil { return Account{}, err @@ -63,6 +129,18 @@ func (s *Store) UpdateAccount(ctx context.Context, id int32, p CreateAccountPara return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) } +func (s *Store) UpdateEnvelope(ctx context.Context, id int32, p CreateEnvelopeParams) (Account, error) { + rows, err := s.pool.Query(ctx, + `UPDATE account SET nom = $2, objectif = $3 + WHERE id = $1 AND master_account_id IS NOT NULL + RETURNING `+accountCols, + id, p.Nom, p.Objectif) + if err != nil { + return Account{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) +} + func (s *Store) DeleteAccount(ctx context.Context, id int32) error { _, err := s.pool.Exec(ctx, `DELETE FROM account WHERE id = $1`, id) return err diff --git a/migrations/000004_envelopes_and_transactions.down.sql b/migrations/000004_envelopes_and_transactions.down.sql new file mode 100644 index 0000000..0aac6c4 --- /dev/null +++ b/migrations/000004_envelopes_and_transactions.down.sql @@ -0,0 +1,18 @@ +ALTER TABLE transaction DROP CONSTRAINT chk_transaction_sides; + +ALTER TABLE transaction + DROP COLUMN envelope_source_id, + DROP COLUMN envelope_dest_id, + ADD COLUMN envelope_id INTEGER REFERENCES envelope(id); + +ALTER TABLE transaction ADD CONSTRAINT chk_transaction_sides CHECK ( + (account_source_id IS NOT NULL AND instrument_source_id IS NOT NULL AND quantite_source IS NOT NULL + OR account_source_id IS NULL AND instrument_source_id IS NULL AND quantite_source IS NULL) + AND + (account_dest_id IS NOT NULL AND instrument_dest_id IS NOT NULL AND quantite_dest IS NOT NULL + OR account_dest_id IS NULL AND instrument_dest_id IS NULL AND quantite_dest IS NULL) + AND + (account_source_id IS NOT NULL OR account_dest_id IS NOT NULL) +); + +DROP TABLE IF EXISTS envelope_snapshot; diff --git a/migrations/000004_envelopes_and_transactions.up.sql b/migrations/000004_envelopes_and_transactions.up.sql new file mode 100644 index 0000000..8c35fbb --- /dev/null +++ b/migrations/000004_envelopes_and_transactions.up.sql @@ -0,0 +1,49 @@ +-- Snapshots dédiés aux enveloppes (strictement monétaires = valeur EUR uniquement) +CREATE TABLE envelope_snapshot ( + date DATE NOT NULL, + envelope_id INTEGER NOT NULL REFERENCES envelope(id) ON DELETE CASCADE, + valeur NUMERIC(24, 8) NOT NULL, + PRIMARY KEY (date, envelope_id) +); +SELECT create_hypertable('envelope_snapshot', by_range('date')); +CREATE INDEX idx_envelope_snapshot_lookup ON envelope_snapshot(envelope_id, date DESC); + +-- Mise à jour de la table transaction : +-- Les enveloppes deviennent de vrais participants source/dest (plus de envelope_id de catégorie) +ALTER TABLE transaction + DROP COLUMN envelope_id, + ADD COLUMN envelope_source_id INTEGER REFERENCES envelope(id), + ADD COLUMN envelope_dest_id INTEGER REFERENCES envelope(id); + +-- Remplacement de la contrainte CHECK pour couvrir account et envelope comme participants +ALTER TABLE transaction DROP CONSTRAINT chk_transaction_sides; + +ALTER TABLE transaction ADD CONSTRAINT chk_transaction_sides CHECK ( + -- Côté source : au plus un parmi (account, envelope), les trois champs cohérents ou tous null + ( + (account_source_id IS NOT NULL AND envelope_source_id IS NULL + OR account_source_id IS NULL AND envelope_source_id IS NOT NULL + OR account_source_id IS NULL AND envelope_source_id IS NULL) + AND + (((account_source_id IS NOT NULL OR envelope_source_id IS NOT NULL) + AND instrument_source_id IS NOT NULL AND quantite_source IS NOT NULL) + OR (account_source_id IS NULL AND envelope_source_id IS NULL + AND instrument_source_id IS NULL AND quantite_source IS NULL)) + ) + AND + -- Côté dest : même logique + ( + (account_dest_id IS NOT NULL AND envelope_dest_id IS NULL + OR account_dest_id IS NULL AND envelope_dest_id IS NOT NULL + OR account_dest_id IS NULL AND envelope_dest_id IS NULL) + AND + (((account_dest_id IS NOT NULL OR envelope_dest_id IS NOT NULL) + AND instrument_dest_id IS NOT NULL AND quantite_dest IS NOT NULL) + OR (account_dest_id IS NULL AND envelope_dest_id IS NULL + AND instrument_dest_id IS NULL AND quantite_dest IS NULL)) + ) + AND + -- Au moins un côté doit exister + (account_source_id IS NOT NULL OR envelope_source_id IS NOT NULL + OR account_dest_id IS NOT NULL OR envelope_dest_id IS NOT NULL) +); diff --git a/migrations/000005_envelope_as_account.down.sql b/migrations/000005_envelope_as_account.down.sql new file mode 100644 index 0000000..85faddb --- /dev/null +++ b/migrations/000005_envelope_as_account.down.sql @@ -0,0 +1,59 @@ +-- Recréation de la table envelope +CREATE TABLE envelope ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES account(id) ON DELETE CASCADE, + nom TEXT NOT NULL, + objectif TEXT, + montant_alloue NUMERIC(24, 8) NOT NULL DEFAULT 0 +); + +-- Remigration des sous-comptes vers envelope +INSERT INTO envelope (account_id, nom, objectif, montant_alloue) +SELECT master_account_id, nom, objectif, COALESCE(montant_alloue, 0) +FROM account WHERE master_account_id IS NOT NULL; + +-- Suppression des sous-comptes de account +DELETE FROM account WHERE master_account_id IS NOT NULL; + +ALTER TABLE account + DROP COLUMN master_account_id, + DROP COLUMN objectif, + DROP COLUMN montant_alloue; + +-- Restauration envelope_snapshot +CREATE TABLE envelope_snapshot ( + date DATE NOT NULL, + envelope_id INTEGER NOT NULL REFERENCES envelope(id) ON DELETE CASCADE, + valeur NUMERIC(24, 8) NOT NULL, + PRIMARY KEY (date, envelope_id) +); +SELECT create_hypertable('envelope_snapshot', by_range('date')); + +-- Restauration transaction avec envelope_source/dest +ALTER TABLE transaction + DROP CONSTRAINT chk_transaction_sides, + ADD COLUMN envelope_source_id INTEGER REFERENCES envelope(id), + ADD COLUMN envelope_dest_id INTEGER REFERENCES envelope(id); + +ALTER TABLE transaction ADD CONSTRAINT chk_transaction_sides CHECK ( + (account_source_id IS NOT NULL AND envelope_source_id IS NULL + OR account_source_id IS NULL AND envelope_source_id IS NOT NULL + OR account_source_id IS NULL AND envelope_source_id IS NULL) + AND + (((account_source_id IS NOT NULL OR envelope_source_id IS NOT NULL) + AND instrument_source_id IS NOT NULL AND quantite_source IS NOT NULL) + OR (account_source_id IS NULL AND envelope_source_id IS NULL + AND instrument_source_id IS NULL AND quantite_source IS NULL)) + AND + (account_dest_id IS NOT NULL AND envelope_dest_id IS NULL + OR account_dest_id IS NULL AND envelope_dest_id IS NOT NULL + OR account_dest_id IS NULL AND envelope_dest_id IS NULL) + AND + (((account_dest_id IS NOT NULL OR envelope_dest_id IS NOT NULL) + AND instrument_dest_id IS NOT NULL AND quantite_dest IS NOT NULL) + OR (account_dest_id IS NULL AND envelope_dest_id IS NULL + AND instrument_dest_id IS NULL AND quantite_dest IS NULL)) + AND + (account_source_id IS NOT NULL OR envelope_source_id IS NOT NULL + OR account_dest_id IS NOT NULL OR envelope_dest_id IS NOT NULL) +); diff --git a/migrations/000005_envelope_as_account.up.sql b/migrations/000005_envelope_as_account.up.sql new file mode 100644 index 0000000..3b86a96 --- /dev/null +++ b/migrations/000005_envelope_as_account.up.sql @@ -0,0 +1,50 @@ +-- Les enveloppes deviennent des comptes avec master_account_id +-- Un sous-compte hérite du type et de la devise de son compte maître +ALTER TABLE account + ADD COLUMN master_account_id INTEGER REFERENCES account(id) ON DELETE CASCADE, + ADD COLUMN objectif TEXT, + ADD COLUMN montant_alloue NUMERIC(24, 8); + +-- Migration des enveloppes existantes vers account +INSERT INTO account (nom, type, devise_reference, master_account_id, objectif, montant_alloue) +SELECT e.nom, a.type, a.devise_reference, e.account_id, e.objectif, e.montant_alloue +FROM envelope e +JOIN account a ON a.id = e.account_id; + +-- Suppression des champs envelope_source/dest ajoutés en 000004 +-- (les enveloppes étant désormais des comptes, account_source_id suffit) +ALTER TABLE transaction + DROP CONSTRAINT chk_transaction_sides, + DROP COLUMN envelope_source_id, + DROP COLUMN envelope_dest_id; + +ALTER TABLE transaction ADD CONSTRAINT chk_transaction_sides CHECK ( + ( + account_source_id IS NOT NULL AND + instrument_source_id IS NOT NULL AND + quantite_source IS NOT NULL + OR + account_source_id IS NULL AND + instrument_source_id IS NULL AND + quantite_source IS NULL + ) + AND + ( + account_dest_id IS NOT NULL AND + instrument_dest_id IS NOT NULL AND + quantite_dest IS NOT NULL + OR + account_dest_id IS NULL AND + instrument_dest_id IS NULL AND + quantite_dest IS NULL + ) + AND + (account_source_id IS NOT NULL OR account_dest_id IS NOT NULL) +); + +-- Suppression de la FK envelope_id dans recurring_rule avant de dropper envelope +ALTER TABLE recurring_rule DROP COLUMN envelope_id; + +-- Suppression des tables envelope devenues inutiles +DROP TABLE IF EXISTS envelope_snapshot; +DROP TABLE IF EXISTS envelope; diff --git a/migrations/000006_drop_montant_alloue.down.sql b/migrations/000006_drop_montant_alloue.down.sql new file mode 100644 index 0000000..dc51e8f --- /dev/null +++ b/migrations/000006_drop_montant_alloue.down.sql @@ -0,0 +1 @@ +ALTER TABLE account ADD COLUMN montant_alloue NUMERIC(24, 8); diff --git a/migrations/000006_drop_montant_alloue.up.sql b/migrations/000006_drop_montant_alloue.up.sql new file mode 100644 index 0000000..e3b6931 --- /dev/null +++ b/migrations/000006_drop_montant_alloue.up.sql @@ -0,0 +1,3 @@ +-- montant_alloue retiré : le solde réel d'une enveloppe est dérivé +-- des transactions et stocké dans account_snapshot +ALTER TABLE account DROP COLUMN montant_alloue; From 43e8794998f3936f008064dd6ee7558ede54fc01 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 13:43:08 +0200 Subject: [PATCH 3/3] add doc api Signed-off-by: GnomeZworc --- docs/README.md | 1 + docs/api.md | 247 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 docs/api.md diff --git a/docs/README.md b/docs/README.md index 725d233..eac1a58 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ - [how to dev](./dev.md) - [how to release](./release.md) +- [API reference](./api.md) ## Release list - [v0.1.0 Cachalot](./docs/release/v0.1.0_cachalot.md) diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..931899a --- /dev/null +++ b/docs/api.md @@ -0,0 +1,247 @@ +# API Reference + +Base URL : `http://localhost:8080` + +Toutes les réponses sont en `application/json`. Les erreurs retournent `{"error": "message"}`. + +--- + +## Health + +### `GET /health` + +Vérifie la disponibilité du serveur et de la base de données. + +**Réponse `200`** +```json +{ "status": "ok" } +``` + +**Réponse `503`** — base indisponible + +--- + +## Instruments + +Un instrument représente tout actif coté : devise (EUR), action, ETF, crypto. +Le prix EUR vaut toujours 1 et est inséré automatiquement au démarrage. + +### `GET /instruments` + +Liste tous les instruments. + +**Réponse `200`** +```json +[ + { "id": 1, "type": "devise", "code": "EUR", "name": "Euro", "devise_cotation": "EUR" }, + { "id": 2, "type": "etf", "code": "LU1681043599", "name": "Amundi MSCI World", "devise_cotation": "EUR" } +] +``` + +--- + +### `GET /instruments/{id}` + +**Réponse `200`** +```json +{ "id": 2, "type": "etf", "code": "LU1681043599", "name": "Amundi MSCI World", "devise_cotation": "EUR" } +``` + +**Réponse `404`** — instrument introuvable + +--- + +### `POST /instruments` + +**Corps** +| Champ | Type | Requis | Description | +|---|---|---|---| +| `type` | string | ✅ | `devise`, `action`, `etf`, `crypto` | +| `code` | string | ✅ | ISIN (actions/ETF), ticker (crypto), code devise | +| `name` | string | ✅ | Nom lisible | +| `devise_cotation` | string | | Devise de cotation (défaut : `EUR`) | + +**Réponse `201`** +```json +{ "id": 3, "type": "crypto", "code": "BTC", "name": "Bitcoin", "devise_cotation": "EUR" } +``` + +> **Pipeline de prix** : le ticker Yahoo Finance (actions/ETF) est résolu automatiquement via OpenFIGI au prochain cycle. L'ID CoinGecko (crypto) est résolu via l'API search CoinGecko. Les résolutions sont mises en cache dans `instrument_ticker_cache`. + +--- + +### `PUT /instruments/{id}` + +Même corps que `POST`. Retourne l'instrument mis à jour. + +--- + +### `DELETE /instruments/{id}` + +**Réponse `204`** — supprimé + +--- + +## Comptes + +Un compte est un conteneur de positions. Son solde est entièrement dérivé des transactions (aucun solde stocké). + +Les enveloppes sont des sous-comptes monétaires liés à un compte maître (voir section Enveloppes). +`GET /accounts/{id}` retourne toujours le compte avec sa liste d'enveloppes. + +### `GET /accounts` + +Liste les comptes maîtres (sans les enveloppes). + +**Réponse `200`** +```json +[ + { + "id": 1, + "nom": "Livret A", + "type": "livret", + "devise_reference": "EUR", + "plafond": 22950 + } +] +``` + +--- + +### `GET /accounts/{id}` + +Retourne le compte et ses enveloppes. + +**Réponse `200`** +```json +{ + "id": 1, + "nom": "Livret A", + "type": "livret", + "devise_reference": "EUR", + "plafond": 22950, + "envelopes": [ + { + "id": 3, + "nom": "Vacances", + "type": "livret", + "devise_reference": "EUR", + "master_account_id": 1, + "objectif": "Road trip USA" + } + ] +} +``` + +**Réponse `404`** — compte introuvable + +--- + +### `POST /accounts` + +Crée un compte maître. + +**Corps** +| Champ | Type | Requis | Description | +|---|---|---|---| +| `nom` | string | ✅ | Nom du compte | +| `type` | string | ✅ | `courant`, `livret`, `pea`, `cto`, `crypto`, ... | +| `devise_reference` | string | | Devise de référence (défaut : `EUR`) | +| `plafond` | number | | Plafond réglementaire (indicatif) | + +**Réponse `201`** +```json +{ "id": 1, "nom": "Livret A", "type": "livret", "devise_reference": "EUR", "plafond": 22950 } +``` + +--- + +### `PUT /accounts/{id}` + +Met à jour un compte maître. Même corps que `POST`. + +--- + +### `DELETE /accounts/{id}` + +Supprime le compte **et toutes ses enveloppes** (CASCADE). + +**Réponse `204`** + +--- + +## Enveloppes + +Une enveloppe est un sous-compte monétaire rattaché à un compte maître. Elle hérite automatiquement du `type` et de la `devise_reference` de son maître. Le solde est dérivé des transactions, comme pour tout compte. + +Règles : +- Un compte maître ne peut pas lui-même être une enveloppe (pas de chaînage) +- Supprimée automatiquement avec son compte maître + +### `GET /accounts/{id}/envelopes` + +Liste les enveloppes d'un compte. + +**Réponse `200`** +```json +[ + { + "id": 3, + "nom": "Vacances", + "type": "livret", + "devise_reference": "EUR", + "master_account_id": 1, + "objectif": "Road trip USA" + } +] +``` + +--- + +### `POST /accounts/{id}/envelopes` + +Crée une enveloppe sous le compte `{id}`. + +**Corps** +| Champ | Type | Requis | Description | +|---|---|---|---| +| `nom` | string | ✅ | Nom de l'enveloppe | +| `objectif` | string | | Description libre de l'objectif | + +**Réponse `201`** +```json +{ + "id": 3, + "nom": "Vacances", + "type": "livret", + "devise_reference": "EUR", + "master_account_id": 1, + "objectif": "Road trip USA" +} +``` + +**Réponse `400`** — le compte est lui-même une enveloppe (chaînage interdit) +**Réponse `404`** — compte introuvable + +--- + +### `PUT /envelopes/{id}` + +Met à jour le nom et/ou l'objectif d'une enveloppe. + +**Corps** +| Champ | Type | Requis | Description | +|---|---|---|---| +| `nom` | string | ✅ | Nouveau nom | +| `objectif` | string | | Nouvel objectif | + +**Réponse `200`** — enveloppe mise à jour +**Réponse `404`** — enveloppe introuvable + +--- + +### `DELETE /envelopes/{id}` + +Supprime l'enveloppe. + +**Réponse `204`**