diff --git a/docs/api.md b/docs/api.md index 931899a..4f5e3a9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -245,3 +245,139 @@ Met à jour le nom et/ou l'objectif d'une enveloppe. Supprime l'enveloppe. **Réponse `204`** + +--- + +## Transactions + +Une transaction est un échange atomique entre deux participants (comptes ou enveloppes) via des instruments. +Chaque côté (source, dest) est soit entièrement renseigné soit entièrement null. Au moins un côté doit exister. + +**Cas couverts :** +| Cas | Source | Dest | +|---|---|---| +| Flux entrant (salaire, dépôt) | null | compte + instrument + quantité | +| Flux sortant (dépense) | compte + instrument + quantité | null | +| Virement / allocation enveloppe | compte + instrument + quantité | compte + instrument + quantité | +| Achat de titres | compte, EUR, montant | compte, ETF, nb parts | +| Swap crypto | wallet, BTC, qté | wallet, ETH, qté | + +Le champ `validated` indique si la transaction est réelle (`true`) ou prévisionnelle (`false`). +Les transactions non validées à date dépassée remontent dans `?pending=true` (liste "à traiter"). + +--- + +### `GET /accounts/{id}/transactions` + +Vue centrée sur le compte : le `montant` est signé (positif = crédit, négatif = débit). +`instrument_id` est celui du côté du compte concerné. +`contrepartie_account_id` est le compte en face (null si flux externe). + +**Paramètres de filtre** +| Paramètre | Type | Description | +|---|---|---| +| `validated` | boolean | `true` ou `false` | +| `pending` | boolean | `true` → non validées avec date ≤ aujourd'hui | +| `from` | string | Date de début `YYYY-MM-DD` | +| `to` | string | Date de fin `YYYY-MM-DD` | + +**Réponse `200`** — triée par date DESC +```json +[ + { + "id": 1, + "date": "2026-06-13", + "montant": 2800.00, + "instrument_id": 1, + "tiers": "Employeur", + "label": "Salaire juin", + "categorie": "salaire", + "validated": true + }, + { + "id": 2, + "date": "2026-06-10", + "montant": -50.00, + "instrument_id": 1, + "label": "Courses", + "categorie": "alimentation", + "validated": true + } +] +``` + +--- + +### `GET /transactions/{id}` + +Retourne la transaction brute (tous les champs source/dest). + +**Réponse `200`** +```json +{ + "id": 1, + "date": "2026-06-13", + "account_dest_id": 1, + "instrument_dest_id": 1, + "quantite_dest": 2800, + "tiers": "Employeur", + "label": "Salaire juin", + "categorie": "salaire", + "validated": true +} +``` + +**Réponse `404`** — introuvable + +--- + +### `POST /transactions` + +**Corps** +| Champ | Type | Requis | Description | +|---|---|---|---| +| `date` | string | ✅ | `YYYY-MM-DD` | +| `label` | string | ✅ | Libellé | +| `validated` | boolean | | Défaut `false` | +| `account_source_id` | integer | | Compte source | +| `instrument_source_id` | integer | | Instrument source | +| `quantite_source` | number | | Quantité source | +| `account_dest_id` | integer | | Compte dest | +| `instrument_dest_id` | integer | | Instrument dest | +| `quantite_dest` | number | | Quantité dest | +| `tiers` | string | | Tiers externe (employeur, commerçant…) | +| `categorie` | string | | Catégorie libre | + +> Les trois champs de chaque côté (account, instrument, quantite) doivent être tous renseignés ou tous null. + +**Réponse `201`** — transaction créée (format brut) +**Réponse `400`** — body invalide, label manquant, date manquante, ou violation de la contrainte source/dest + +--- + +### `PUT /transactions/{id}` + +Mise à jour complète. Même corps que `POST`. + +**Réponse `200`** — transaction mise à jour (format brut) +**Réponse `404`** — introuvable + +--- + +### `PATCH /transactions/{id}/validate` + +Valide ou dé-valide une transaction. + +**Corps** +```json +{ "validated": true } +``` + +**Réponse `200`** — transaction avec le nouveau statut +**Réponse `404`** — introuvable + +--- + +### `DELETE /transactions/{id}` + +**Réponse `204`** diff --git a/internal/handler/transaction.go b/internal/handler/transaction.go new file mode 100644 index 0000000..75c115a --- /dev/null +++ b/internal/handler/transaction.go @@ -0,0 +1,188 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "git.g3e.fr/H6N/account/internal/store" + "github.com/jackc/pgx/v5" +) + +type TransactionHandler struct { + store *store.Store +} + +func NewTransactionHandler(s *store.Store) *TransactionHandler { + return &TransactionHandler{store: s} +} + +func (h *TransactionHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /accounts/{id}/transactions", h.listForAccount) + mux.HandleFunc("POST /transactions", h.create) + mux.HandleFunc("GET /transactions/{id}", h.get) + mux.HandleFunc("PUT /transactions/{id}", h.update) + mux.HandleFunc("DELETE /transactions/{id}", h.delete) + mux.HandleFunc("PATCH /transactions/{id}/validate", h.validate) +} + +func (h *TransactionHandler) listForAccount(w http.ResponseWriter, r *http.Request) { + accountID, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid account id") + return + } + f := store.AccountTransactionFilters{ + Validated: parseBoolParam(r, "validated"), + Pending: parseBoolParam(r, "pending"), + From: parseStringParam(r, "from"), + To: parseStringParam(r, "to"), + } + txs, err := h.store.ListAccountTransactions(r.Context(), accountID, f) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list transactions") + return + } + writeJSON(w, http.StatusOK, txs) +} + +func (h *TransactionHandler) get(w http.ResponseWriter, r *http.Request) { + id, err := parseTxID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + tx, err := h.store.GetTransaction(r.Context(), id) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "transaction not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get transaction") + return + } + writeJSON(w, http.StatusOK, tx) +} + +func (h *TransactionHandler) create(w http.ResponseWriter, r *http.Request) { + var p store.CreateTransactionParams + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if p.Label == "" { + writeError(w, http.StatusBadRequest, "label is required") + return + } + if p.Date == "" { + writeError(w, http.StatusBadRequest, "date is required") + return + } + tx, err := h.store.CreateTransaction(r.Context(), p) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create transaction") + return + } + writeJSON(w, http.StatusCreated, tx) +} + +func (h *TransactionHandler) update(w http.ResponseWriter, r *http.Request) { + id, err := parseTxID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var p store.CreateTransactionParams + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if p.Label == "" { + writeError(w, http.StatusBadRequest, "label is required") + return + } + tx, err := h.store.UpdateTransaction(r.Context(), id, p) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "transaction not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update transaction") + return + } + writeJSON(w, http.StatusOK, tx) +} + +func (h *TransactionHandler) delete(w http.ResponseWriter, r *http.Request) { + id, err := parseTxID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.store.DeleteTransaction(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete transaction") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *TransactionHandler) validate(w http.ResponseWriter, r *http.Request) { + id, err := parseTxID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var body struct { + Validated bool `json:"validated"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + tx, err := h.store.SetValidated(r.Context(), id, body.Validated) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "transaction not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update transaction") + return + } + writeJSON(w, http.StatusOK, tx) +} + +func parseTxID(r *http.Request) (int64, error) { + v, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + return v, err +} + +func parseIntParam(r *http.Request, key string) *int32 { + v := r.URL.Query().Get(key) + if v == "" { + return nil + } + n, err := strconv.Atoi(v) + if err != nil { + return nil + } + i := int32(n) + return &i +} + +func parseBoolParam(r *http.Request, key string) *bool { + v := r.URL.Query().Get(key) + if v == "" { + return nil + } + b := v == "true" + return &b +} + +func parseStringParam(r *http.Request, key string) *string { + v := r.URL.Query().Get(key) + if v == "" { + return nil + } + return &v +} diff --git a/internal/server/server.go b/internal/server/server.go index d53b3ed..99274f0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -38,6 +38,7 @@ func (s *Server) routes() { handler.NewInstrumentHandler(st).RegisterRoutes(s.mux) handler.NewAccountHandler(st).RegisterRoutes(s.mux) + handler.NewTransactionHandler(st).RegisterRoutes(s.mux) s.mux.HandleFunc("GET /health", s.handleHealth) } diff --git a/internal/store/transaction.go b/internal/store/transaction.go new file mode 100644 index 0000000..83f5836 --- /dev/null +++ b/internal/store/transaction.go @@ -0,0 +1,167 @@ +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type Transaction struct { + ID int64 `json:"id"` + Date string `json:"date"` + AccountSourceID *int32 `json:"account_source_id,omitempty"` + InstrumentSourceID *int32 `json:"instrument_source_id,omitempty"` + QuantiteSource *float64 `json:"quantite_source,omitempty"` + AccountDestID *int32 `json:"account_dest_id,omitempty"` + InstrumentDestID *int32 `json:"instrument_dest_id,omitempty"` + QuantiteDest *float64 `json:"quantite_dest,omitempty"` + Tiers *string `json:"tiers,omitempty"` + Label string `json:"label"` + Categorie *string `json:"categorie,omitempty"` + Validated bool `json:"validated"` + RecurringRuleID *int32 `json:"recurring_rule_id,omitempty"` +} + +// AccountTransaction est la vue centrée sur un compte : +// montant signé (+ crédit, - débit), instrument du côté concerné. +type AccountTransaction struct { + ID int64 `json:"id"` + Date string `json:"date"` + Montant float64 `json:"montant"` + InstrumentID int32 `json:"instrument_id"` + Tiers *string `json:"tiers,omitempty"` + Label string `json:"label"` + Categorie *string `json:"categorie,omitempty"` + Validated bool `json:"validated"` + ContrepartieAccountID *int32 `json:"contrepartie_account_id,omitempty"` +} + +type CreateTransactionParams struct { + Date string `json:"date"` + AccountSourceID *int32 `json:"account_source_id,omitempty"` + InstrumentSourceID *int32 `json:"instrument_source_id,omitempty"` + QuantiteSource *float64 `json:"quantite_source,omitempty"` + AccountDestID *int32 `json:"account_dest_id,omitempty"` + InstrumentDestID *int32 `json:"instrument_dest_id,omitempty"` + QuantiteDest *float64 `json:"quantite_dest,omitempty"` + Tiers *string `json:"tiers,omitempty"` + Label string `json:"label"` + Categorie *string `json:"categorie,omitempty"` + Validated bool `json:"validated"` +} + +type AccountTransactionFilters struct { + Validated *bool + Pending *bool + From *string + To *string +} + +const txCols = `id, date::text, account_source_id, instrument_source_id, quantite_source, + account_dest_id, instrument_dest_id, quantite_dest, + tiers, label, categorie, validated, recurring_rule_id` + +// ListAccountTransactions retourne les transactions d'un compte avec montant signé. +func (s *Store) ListAccountTransactions(ctx context.Context, accountID int32, f AccountTransactionFilters) ([]AccountTransaction, error) { + q := ` + SELECT + id, + date::text, + CASE WHEN account_dest_id = $1 THEN quantite_dest ELSE -quantite_source END AS montant, + CASE WHEN account_dest_id = $1 THEN instrument_dest_id ELSE instrument_source_id END AS instrument_id, + tiers, label, categorie, validated, + CASE WHEN account_dest_id = $1 THEN account_source_id ELSE account_dest_id END AS contrepartie_account_id + FROM transaction + WHERE (account_source_id = $1 OR account_dest_id = $1)` + + args := []any{accountID} + i := 2 + + if f.Pending != nil && *f.Pending { + q += ` AND validated = false AND date <= CURRENT_DATE` + } else if f.Validated != nil { + q += fmt.Sprintf(` AND validated = $%d`, i) + args = append(args, *f.Validated) + i++ + } + if f.From != nil { + q += fmt.Sprintf(` AND date >= $%d::date`, i) + args = append(args, *f.From) + i++ + } + if f.To != nil { + q += fmt.Sprintf(` AND date <= $%d::date`, i) + args = append(args, *f.To) + i++ + } + + q += ` ORDER BY date DESC, id DESC` + + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByName[AccountTransaction]) +} + +func (s *Store) GetTransaction(ctx context.Context, id int64) (Transaction, error) { + rows, err := s.pool.Query(ctx, + `SELECT `+txCols+` FROM transaction WHERE id = $1`, id) + if err != nil { + return Transaction{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction]) +} + +func (s *Store) CreateTransaction(ctx context.Context, p CreateTransactionParams) (Transaction, error) { + rows, err := s.pool.Query(ctx, + `INSERT INTO transaction ( + date, account_source_id, instrument_source_id, quantite_source, + account_dest_id, instrument_dest_id, quantite_dest, + tiers, label, categorie, validated + ) VALUES ( + $1::date, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + ) RETURNING `+txCols, + p.Date, p.AccountSourceID, p.InstrumentSourceID, p.QuantiteSource, + p.AccountDestID, p.InstrumentDestID, p.QuantiteDest, + p.Tiers, p.Label, p.Categorie, p.Validated) + if err != nil { + return Transaction{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction]) +} + +func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransactionParams) (Transaction, error) { + rows, err := s.pool.Query(ctx, + `UPDATE transaction SET + date = $2::date, + account_source_id = $3, instrument_source_id = $4, quantite_source = $5, + account_dest_id = $6, instrument_dest_id = $7, quantite_dest = $8, + tiers = $9, label = $10, categorie = $11, validated = $12 + WHERE id = $1 + RETURNING `+txCols, + id, p.Date, + p.AccountSourceID, p.InstrumentSourceID, p.QuantiteSource, + p.AccountDestID, p.InstrumentDestID, p.QuantiteDest, + p.Tiers, p.Label, p.Categorie, p.Validated) + if err != nil { + return Transaction{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction]) +} + +func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Transaction, error) { + rows, err := s.pool.Query(ctx, + `UPDATE transaction SET validated = $2 WHERE id = $1 RETURNING `+txCols, + id, validated) + if err != nil { + return Transaction{}, err + } + return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction]) +} + +func (s *Store) DeleteTransaction(ctx context.Context, id int64) error { + _, err := s.pool.Exec(ctx, `DELETE FROM transaction WHERE id = $1`, id) + return err +}