add transaction handle
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
6aa088dbeb
commit
5f6b13ce78
3 changed files with 356 additions and 0 deletions
188
internal/handler/transaction.go
Normal file
188
internal/handler/transaction.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
167
internal/store/transaction.go
Normal file
167
internal/store/transaction.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue