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;