Merge branch 'feature/transaction'

This commit is contained in:
GnomeZworc 2026-06-13 14:09:33 +02:00
commit 839f26d168
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
4 changed files with 492 additions and 0 deletions

View file

@ -245,3 +245,139 @@ Met à jour le nom et/ou l'objectif d'une enveloppe.
Supprime l'enveloppe. Supprime l'enveloppe.
**Réponse `204`** **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`**

View file

@ -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
}

View file

@ -38,6 +38,7 @@ func (s *Server) routes() {
handler.NewInstrumentHandler(st).RegisterRoutes(s.mux) handler.NewInstrumentHandler(st).RegisterRoutes(s.mux)
handler.NewAccountHandler(st).RegisterRoutes(s.mux) handler.NewAccountHandler(st).RegisterRoutes(s.mux)
handler.NewTransactionHandler(st).RegisterRoutes(s.mux)
s.mux.HandleFunc("GET /health", s.handleHealth) s.mux.HandleFunc("GET /health", s.handleHealth)
} }

View file

@ -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
}