Merge branch 'feature/snapshot'
This commit is contained in:
commit
e22e8d4e5a
36 changed files with 1983 additions and 324 deletions
2
Makefile
2
Makefile
|
|
@ -16,7 +16,7 @@ run:
|
|||
go run ./cmd/api
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
go test ./cmd/api/ -v -run TestAPI -timeout 30s
|
||||
|
||||
## Docker
|
||||
docker-up:
|
||||
|
|
|
|||
566
cmd/api/integration_test.go
Normal file
566
cmd/api/integration_test.go
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.g3e.fr/H6N/account/internal/server"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const testDBURL = "postgres://account:account@localhost:5432/account?sslmode=disable"
|
||||
|
||||
// ts wraps a live httptest.Server + helpers for the integration suite.
|
||||
type ts struct {
|
||||
*testing.T
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func (s *ts) do(method, path string, body any) *http.Response {
|
||||
s.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
s.Fatalf("marshal: %v", err)
|
||||
}
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, s.srv.URL+path, r)
|
||||
if err != nil {
|
||||
s.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Owner-ID", "1")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
s.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *ts) decode(resp *http.Response, out any) {
|
||||
s.Helper()
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
s.Fatalf("decode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ts) mustStatus(resp *http.Response, want int) {
|
||||
s.Helper()
|
||||
if resp.StatusCode != want {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
s.Fatalf("expected HTTP %d, got %d: %s", want, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func setupSuite(t *testing.T) *ts {
|
||||
t.Helper()
|
||||
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = testDBURL
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Skipf("cannot connect to test DB: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("test DB unreachable: %v", err)
|
||||
}
|
||||
|
||||
// Nettoyer les données avant chaque run (ordre FK)
|
||||
_, err = pool.Exec(ctx, `
|
||||
TRUNCATE TABLE transaction, recurring_rule, account_snapshot,
|
||||
position_snapshot, price_history, instrument_ticker_cache,
|
||||
account, instrument RESTART IDENTITY CASCADE
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
// Réinsérer EUR
|
||||
_, err = pool.Exec(ctx, `INSERT INTO instrument (type, code, name, devise_cotation) VALUES ('devise','EUR','Euro','EUR')`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed EUR: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := server.New(nil, pool, logger)
|
||||
hs := httptest.NewServer(srv)
|
||||
|
||||
t.Cleanup(func() {
|
||||
hs.Close()
|
||||
pool.Close()
|
||||
})
|
||||
|
||||
return &ts{T: t, srv: hs}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
s := setupSuite(t)
|
||||
|
||||
// IDs partagés entre sous-tests
|
||||
var (
|
||||
eurID, etfID int32
|
||||
accID, peaID int32
|
||||
envID int32
|
||||
tx1, tx2 int64
|
||||
)
|
||||
|
||||
// ── Instruments ──────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("GET /instruments retourne EUR", func(t *testing.T) {
|
||||
resp := s.do("GET", "/instruments", nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var list []map[string]any
|
||||
s.decode(resp, &list)
|
||||
if len(list) != 1 || list[0]["code"] != "EUR" {
|
||||
t.Fatalf("expected [EUR], got %v", list)
|
||||
}
|
||||
eurID = int32(list[0]["id"].(float64))
|
||||
})
|
||||
|
||||
t.Run("POST /instruments crée un ETF", func(t *testing.T) {
|
||||
resp := s.do("POST", "/instruments", map[string]any{
|
||||
"type": "etf", "code": "LU1681043599",
|
||||
"name": "Amundi MSCI World", "devise_cotation": "EUR",
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
var inst map[string]any
|
||||
s.decode(resp, &inst)
|
||||
if inst["code"] != "LU1681043599" {
|
||||
t.Fatalf("unexpected instrument: %v", inst)
|
||||
}
|
||||
etfID = int32(inst["id"].(float64))
|
||||
})
|
||||
|
||||
t.Run("GET /instruments/{id}", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/instruments/%d", etfID), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var inst map[string]any
|
||||
s.decode(resp, &inst)
|
||||
if inst["name"] != "Amundi MSCI World" {
|
||||
t.Fatalf("unexpected name: %v", inst["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT /instruments/{id}", func(t *testing.T) {
|
||||
resp := s.do("PUT", fmt.Sprintf("/instruments/%d", etfID), map[string]any{
|
||||
"type": "etf", "code": "LU1681043599",
|
||||
"name": "Amundi MSCI World ETF", "devise_cotation": "EUR",
|
||||
})
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var inst map[string]any
|
||||
s.decode(resp, &inst)
|
||||
if inst["name"] != "Amundi MSCI World ETF" {
|
||||
t.Fatalf("PUT did not update name: %v", inst)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /instruments/{id} 404", func(t *testing.T) {
|
||||
resp := s.do("GET", "/instruments/9999", nil)
|
||||
s.mustStatus(resp, http.StatusNotFound)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
// ── Accounts ─────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("POST /accounts crée Livret A", func(t *testing.T) {
|
||||
resp := s.do("POST", "/accounts", map[string]any{
|
||||
"nom": "Livret A", "type": "livret", "plafond": 22950,
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
var acc map[string]any
|
||||
s.decode(resp, &acc)
|
||||
if acc["nom"] != "Livret A" {
|
||||
t.Fatalf("unexpected account: %v", acc)
|
||||
}
|
||||
if acc["devise_reference"] != "EUR" {
|
||||
t.Fatalf("devise_reference should default to EUR, got %v", acc["devise_reference"])
|
||||
}
|
||||
accID = int32(acc["id"].(float64))
|
||||
})
|
||||
|
||||
t.Run("POST /accounts crée PEA", func(t *testing.T) {
|
||||
resp := s.do("POST", "/accounts", map[string]any{
|
||||
"nom": "PEA", "type": "pea",
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
var acc map[string]any
|
||||
s.decode(resp, &acc)
|
||||
peaID = int32(acc["id"].(float64))
|
||||
})
|
||||
|
||||
t.Run("GET /accounts liste les comptes maîtres", func(t *testing.T) {
|
||||
resp := s.do("GET", "/accounts", nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var list []map[string]any
|
||||
s.decode(resp, &list)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 accounts, got %d", len(list))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /accounts/{id} inclut les enveloppes vides", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/accounts/%d", accID), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var acc map[string]any
|
||||
s.decode(resp, &acc)
|
||||
if acc["envelopes"] == nil {
|
||||
t.Fatal("envelopes field missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT /accounts/{id} préserve devise_reference", func(t *testing.T) {
|
||||
resp := s.do("PUT", fmt.Sprintf("/accounts/%d", accID), map[string]any{
|
||||
"nom": "Livret A CIC", "type": "livret", "plafond": 22950,
|
||||
})
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var acc map[string]any
|
||||
s.decode(resp, &acc)
|
||||
if acc["devise_reference"] != "EUR" {
|
||||
t.Fatalf("devise_reference lost after PUT: %v", acc["devise_reference"])
|
||||
}
|
||||
if acc["nom"] != "Livret A CIC" {
|
||||
t.Fatalf("nom not updated: %v", acc["nom"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /accounts/{id} 404", func(t *testing.T) {
|
||||
resp := s.do("GET", "/accounts/9999", nil)
|
||||
s.mustStatus(resp, http.StatusNotFound)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
// ── Envelopes ─────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("POST /accounts/{id}/envelopes crée une enveloppe", func(t *testing.T) {
|
||||
resp := s.do("POST", fmt.Sprintf("/accounts/%d/envelopes", accID), map[string]any{
|
||||
"nom": "Vacances", "objectif": "Road trip USA",
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
var env map[string]any
|
||||
s.decode(resp, &env)
|
||||
if env["type"] != "livret" {
|
||||
t.Fatalf("envelope should inherit type from master, got %v", env["type"])
|
||||
}
|
||||
if env["devise_reference"] != "EUR" {
|
||||
t.Fatalf("envelope should inherit devise, got %v", env["devise_reference"])
|
||||
}
|
||||
if int32(env["master_account_id"].(float64)) != accID {
|
||||
t.Fatalf("master_account_id mismatch")
|
||||
}
|
||||
envID = int32(env["id"].(float64))
|
||||
})
|
||||
|
||||
t.Run("GET /accounts/{id}/envelopes", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/accounts/%d/envelopes", accID), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var list []map[string]any
|
||||
s.decode(resp, &list)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 envelope, got %d", len(list))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /accounts/{id} inclut l'enveloppe", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/accounts/%d", accID), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var acc map[string]any
|
||||
s.decode(resp, &acc)
|
||||
envs := acc["envelopes"].([]any)
|
||||
if len(envs) != 1 {
|
||||
t.Fatalf("expected 1 envelope in account, got %d", len(envs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT /envelopes/{id}", func(t *testing.T) {
|
||||
resp := s.do("PUT", fmt.Sprintf("/envelopes/%d", envID), map[string]any{
|
||||
"nom": "Vacances 2027", "objectif": "Japon",
|
||||
})
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var env map[string]any
|
||||
s.decode(resp, &env)
|
||||
if env["objectif"] != "Japon" {
|
||||
t.Fatalf("objectif not updated: %v", env["objectif"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST chaînage d'enveloppe interdit", func(t *testing.T) {
|
||||
resp := s.do("POST", fmt.Sprintf("/accounts/%d/envelopes", envID), map[string]any{
|
||||
"nom": "sous-enveloppe",
|
||||
})
|
||||
s.mustStatus(resp, http.StatusBadRequest)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
// ── Transactions ──────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("POST flux entrant (salaire)", func(t *testing.T) {
|
||||
resp := s.do("POST", "/transactions", map[string]any{
|
||||
"date": "2026-06-01", "label": "Salaire juin",
|
||||
"tiers": "Employeur", "categorie": "salaire",
|
||||
"account_dest_id": accID, "instrument_dest_id": eurID,
|
||||
"quantite_dest": 2800, "validated": true,
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
var tx map[string]any
|
||||
s.decode(resp, &tx)
|
||||
tx1 = int64(tx["id"].(float64))
|
||||
})
|
||||
|
||||
t.Run("POST flux sortant (dépense)", func(t *testing.T) {
|
||||
resp := s.do("POST", "/transactions", map[string]any{
|
||||
"date": "2026-06-05", "label": "Courses",
|
||||
"categorie": "alimentation",
|
||||
"account_source_id": accID, "instrument_source_id": eurID,
|
||||
"quantite_source": 85.50, "validated": true,
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
var tx map[string]any
|
||||
s.decode(resp, &tx)
|
||||
tx2 = int64(tx["id"].(float64))
|
||||
})
|
||||
|
||||
t.Run("POST virement (Livret A → PEA)", func(t *testing.T) {
|
||||
resp := s.do("POST", "/transactions", map[string]any{
|
||||
"date": "2026-06-10", "label": "Versement PEA",
|
||||
"account_source_id": accID, "instrument_source_id": eurID, "quantite_source": 500,
|
||||
"account_dest_id": peaID, "instrument_dest_id": etfID, "quantite_dest": 1.35,
|
||||
"validated": true,
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("POST transaction prévisionnelle", func(t *testing.T) {
|
||||
resp := s.do("POST", "/transactions", map[string]any{
|
||||
"date": "2026-05-15", "label": "Loyer mai",
|
||||
"categorie": "logement",
|
||||
"account_source_id": accID, "instrument_source_id": eurID,
|
||||
"quantite_source": 950, "validated": false,
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("GET /accounts/{id}/transactions vue signée", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/accounts/%d/transactions", accID), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var list []map[string]any
|
||||
s.decode(resp, &list)
|
||||
if len(list) != 4 {
|
||||
t.Fatalf("expected 4 transactions, got %d", len(list))
|
||||
}
|
||||
// Premier résultat = date la plus récente (virement)
|
||||
if list[0]["montant"].(float64) != -500 {
|
||||
t.Fatalf("virement should be -500, got %v", list[0]["montant"])
|
||||
}
|
||||
if list[0]["contrepartie_account_id"] == nil {
|
||||
t.Fatalf("contrepartie_account_id should be set for virement")
|
||||
}
|
||||
// Salaire doit être positif
|
||||
var salaire map[string]any
|
||||
for _, tx := range list {
|
||||
if tx["label"] == "Salaire juin" {
|
||||
salaire = tx
|
||||
break
|
||||
}
|
||||
}
|
||||
if salaire == nil || salaire["montant"].(float64) != 2800 {
|
||||
t.Fatalf("salaire montant should be +2800, got %v", salaire)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /accounts/{id}/transactions?pending=true", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/accounts/%d/transactions?pending=true", accID), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var list []map[string]any
|
||||
s.decode(resp, &list)
|
||||
if len(list) != 1 || list[0]["label"] != "Loyer mai" {
|
||||
t.Fatalf("expected 1 pending transaction (Loyer mai), got %v", list)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /accounts/{id}/transactions?validated=true", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/accounts/%d/transactions?validated=true", accID), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var list []map[string]any
|
||||
s.decode(resp, &list)
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("expected 3 validated transactions, got %d", len(list))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /accounts/{id}/transactions?from&to", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/accounts/%d/transactions?from=2026-06-05&to=2026-06-10", accID), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var list []map[string]any
|
||||
s.decode(resp, &list)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 transactions in range, got %d", len(list))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /transactions/{id} vue brute", func(t *testing.T) {
|
||||
resp := s.do("GET", fmt.Sprintf("/transactions/%d", tx1), nil)
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var tx map[string]any
|
||||
s.decode(resp, &tx)
|
||||
if tx["quantite_dest"].(float64) != 2800 {
|
||||
t.Fatalf("unexpected quantite_dest: %v", tx["quantite_dest"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT /transactions/{id}", func(t *testing.T) {
|
||||
resp := s.do("PUT", fmt.Sprintf("/transactions/%d", tx2), map[string]any{
|
||||
"date": "2026-06-05", "label": "Courses Monoprix",
|
||||
"categorie": "alimentation",
|
||||
"account_source_id": accID, "instrument_source_id": eurID,
|
||||
"quantite_source": 92.30, "validated": true,
|
||||
})
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var tx map[string]any
|
||||
s.decode(resp, &tx)
|
||||
if tx["quantite_source"].(float64) != 92.30 {
|
||||
t.Fatalf("PUT did not update quantite_source: %v", tx["quantite_source"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PATCH /transactions/{id}/validate — valider", func(t *testing.T) {
|
||||
// Créer une transaction non validée à valider
|
||||
resp := s.do("POST", "/transactions", map[string]any{
|
||||
"date": "2026-06-12", "label": "À valider",
|
||||
"account_dest_id": accID, "instrument_dest_id": eurID,
|
||||
"quantite_dest": 100, "validated": false,
|
||||
})
|
||||
s.mustStatus(resp, http.StatusCreated)
|
||||
var created map[string]any
|
||||
s.decode(resp, &created)
|
||||
id := int64(created["id"].(float64))
|
||||
|
||||
resp2 := s.do("PATCH", fmt.Sprintf("/transactions/%d/validate", id), map[string]any{"validated": true})
|
||||
s.mustStatus(resp2, http.StatusOK)
|
||||
var tx map[string]any
|
||||
s.decode(resp2, &tx)
|
||||
if tx["validated"] != true {
|
||||
t.Fatalf("expected validated=true, got %v", tx["validated"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PATCH /transactions/{id}/validate — dévalider", func(t *testing.T) {
|
||||
resp := s.do("PATCH", fmt.Sprintf("/transactions/%d/validate", tx1), map[string]any{"validated": false})
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var tx map[string]any
|
||||
s.decode(resp, &tx)
|
||||
if tx["validated"] != false {
|
||||
t.Fatalf("expected validated=false, got %v", tx["validated"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE /transactions/{id}", func(t *testing.T) {
|
||||
resp := s.do("DELETE", fmt.Sprintf("/transactions/%d", tx2), nil)
|
||||
s.mustStatus(resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
resp2 := s.do("GET", fmt.Sprintf("/transactions/%d", tx2), nil)
|
||||
s.mustStatus(resp2, http.StatusNotFound)
|
||||
resp2.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("DELETE /accounts/{id} supprime les enveloppes en cascade", func(t *testing.T) {
|
||||
// Créer un compte temporaire avec une enveloppe
|
||||
r1 := s.do("POST", "/accounts", map[string]any{"nom": "Temp", "type": "courant"})
|
||||
s.mustStatus(r1, http.StatusCreated)
|
||||
var acc map[string]any
|
||||
s.decode(r1, &acc)
|
||||
tmpID := int32(acc["id"].(float64))
|
||||
|
||||
r2 := s.do("POST", fmt.Sprintf("/accounts/%d/envelopes", tmpID), map[string]any{"nom": "Env Temp"})
|
||||
s.mustStatus(r2, http.StatusCreated)
|
||||
r2.Body.Close()
|
||||
|
||||
r3 := s.do("DELETE", fmt.Sprintf("/accounts/%d", tmpID), nil)
|
||||
s.mustStatus(r3, http.StatusNoContent)
|
||||
r3.Body.Close()
|
||||
|
||||
r4 := s.do("GET", fmt.Sprintf("/accounts/%d", tmpID), nil)
|
||||
s.mustStatus(r4, http.StatusNotFound)
|
||||
r4.Body.Close()
|
||||
})
|
||||
|
||||
// ── Snapshots ─────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("POST transaction invalide le snapshot du compte", func(t *testing.T) {
|
||||
// La transaction salaire (tx1) a déjà été créée pour accID.
|
||||
// On vérifie qu'une invalidation existe pour ce compte.
|
||||
resp := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil)
|
||||
// tx1 a une date passée → invalidation présente → 200 avec from/to
|
||||
s.mustStatus(resp, http.StatusOK)
|
||||
var result map[string]any
|
||||
s.decode(resp, &result)
|
||||
if result["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", result)
|
||||
}
|
||||
if result["account_id"].(float64) != float64(accID) {
|
||||
t.Fatalf("unexpected account_id: %v", result["account_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST /accounts/{id}/snapshots/recompute — aucune invalidation → 204", func(t *testing.T) {
|
||||
// Le recompute précédent a effacé l'invalidation.
|
||||
resp := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil)
|
||||
s.mustStatus(resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("Modifier une transaction re-invalide le compte", func(t *testing.T) {
|
||||
// Créer une nouvelle transaction pour déclencher une invalidation.
|
||||
r1 := s.do("POST", "/transactions", map[string]any{
|
||||
"date": "2026-05-01", "label": "Test invalidation",
|
||||
"account_dest_id": accID, "instrument_dest_id": eurID,
|
||||
"quantite_dest": 50, "validated": true,
|
||||
})
|
||||
s.mustStatus(r1, http.StatusCreated)
|
||||
r1.Body.Close()
|
||||
|
||||
// L'invalidation est présente → recompute retourne 200.
|
||||
r2 := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil)
|
||||
s.mustStatus(r2, http.StatusOK)
|
||||
var result map[string]any
|
||||
s.decode(r2, &result)
|
||||
if result["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST /accounts/{id}/snapshots/recompute — compte inexistant → 404", func(t *testing.T) {
|
||||
resp := s.do("POST", "/accounts/9999/snapshots/recompute", nil)
|
||||
// Compte inexistant : GetInvalidation retourne pgx.ErrNoRows → 404
|
||||
// (en pratique le compte n'existe pas, l'invalidation non plus → 204)
|
||||
// On accepte 204 ou 404
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected 204 or 404 for unknown account, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
// Utiliser les variables pour éviter "declared but not used"
|
||||
_ = eurID
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"git.g3e.fr/H6N/account/internal/pipeline"
|
||||
"git.g3e.fr/H6N/account/internal/scheduler"
|
||||
"git.g3e.fr/H6N/account/internal/server"
|
||||
"git.g3e.fr/H6N/account/internal/snapshot"
|
||||
"git.g3e.fr/H6N/account/internal/store"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
|
@ -44,6 +45,7 @@ func main() {
|
|||
OpenFIGIKey: cfg.OpenFIGIKey,
|
||||
CoinGeckoKey: cfg.CoinGeckoKey,
|
||||
}, logger)
|
||||
snap := snapshot.New(st, logger, cfg.SnapshotHorizonDays).WithBackfiller(pl)
|
||||
|
||||
sched := scheduler.New(logger)
|
||||
sched.Add(scheduler.Job{
|
||||
|
|
@ -56,6 +58,11 @@ func main() {
|
|||
Interval: cfg.PriceCleanInterval,
|
||||
Fn: pl.CleanHistory,
|
||||
})
|
||||
sched.Add(scheduler.Job{
|
||||
Name: "daily-snapshot",
|
||||
Interval: 24 * time.Hour,
|
||||
Fn: snap.DailySnapshot,
|
||||
})
|
||||
sched.Start(ctx)
|
||||
|
||||
srv := server.New(cfg, pool, logger)
|
||||
|
|
|
|||
109
docs/api.md
109
docs/api.md
|
|
@ -6,6 +6,25 @@ Toutes les réponses sont en `application/json`. Les erreurs retournent `{"error
|
|||
|
||||
---
|
||||
|
||||
## Authentification
|
||||
|
||||
Toutes les routes (sauf `GET /health`) requièrent le header :
|
||||
|
||||
```
|
||||
X-Owner-ID: <integer>
|
||||
```
|
||||
|
||||
En dev, c'est l'ID numérique de l'owner. Les comptes, transactions et snapshots sont strictement filtrés par owner — un owner ne peut ni voir ni modifier les ressources d'un autre.
|
||||
|
||||
**Réponse `401`** — header absent ou invalide
|
||||
```json
|
||||
{ "error": "missing X-Owner-ID header" }
|
||||
```
|
||||
|
||||
> À remplacer par JWT en production.
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
### `GET /health`
|
||||
|
|
@ -381,3 +400,93 @@ Valide ou dé-valide une transaction.
|
|||
### `DELETE /transactions/{id}`
|
||||
|
||||
**Réponse `204`**
|
||||
|
||||
---
|
||||
|
||||
## Snapshots
|
||||
|
||||
Les snapshots capturent la valorisation de chaque compte sur une fenêtre glissante :
|
||||
- **Passé** : transactions validées uniquement
|
||||
- **Futur** : toutes les transactions (validées ou non) — projection basée sur les transactions planifiées
|
||||
|
||||
Deux tables sont alimentées :
|
||||
- `position_snapshot` — quantité + valeur par instrument et par compte
|
||||
- `account_snapshot` — valeur totale agrégée par compte
|
||||
|
||||
**Fenêtre** : de la première transaction jusqu'à `today + SNAPSHOT_HORIZON_DAYS` (défaut : 30 jours). Configurable via la variable d'environnement `SNAPSHOT_HORIZON_DAYS`.
|
||||
|
||||
**Toutes les transactions sont comptées** quelle que soit leur validation.
|
||||
Le snapshot représente toujours l'état envisagé complet du compte :
|
||||
transactions confirmées (`validated = true`) **et** planifiées (`validated = false`).
|
||||
La liste `?pending=true` (transactions non validées à date dépassée) reste distincte — c'est un outil de suivi, pas un filtre de calcul.
|
||||
|
||||
**Job `daily-snapshot`** (toutes les 24h) :
|
||||
1. Traite les invalidations en attente
|
||||
2. Recalcule hier (consolide les transactions de la veille)
|
||||
3. Calcule le nouveau jour entrant dans la fenêtre (`today + horizon`)
|
||||
|
||||
---
|
||||
|
||||
### `GET /accounts/{id}/snapshots`
|
||||
|
||||
Liste la valorisation totale du compte jour par jour.
|
||||
|
||||
**Paramètres de filtre**
|
||||
| Paramètre | Type | Description |
|
||||
|---|---|---|
|
||||
| `from` | string | Date de début `YYYY-MM-DD` (optionnel, défaut : première date disponible) |
|
||||
| `to` | string | Date de fin `YYYY-MM-DD` (optionnel, défaut : dernier jour calculé dans la fenêtre) |
|
||||
|
||||
**Réponse `200`** — triée par date ASC
|
||||
```json
|
||||
[
|
||||
{ "date": "2026-06-01", "valeur": 2750.00 },
|
||||
{ "date": "2026-06-02", "valeur": 2800.00 },
|
||||
{ "date": "2026-07-13", "valeur": 2950.00 }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /accounts/{id}/snapshots/positions`
|
||||
|
||||
Liste les positions détaillées (par instrument) du compte jour par jour.
|
||||
|
||||
**Paramètres de filtre** — mêmes que `/snapshots` (`from`, `to`)
|
||||
|
||||
**Réponse `200`** — triée par date ASC puis instrument_id
|
||||
```json
|
||||
[
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"instrument_id": 1,
|
||||
"quantite": 2750.00,
|
||||
"prix_cloture": 1.0,
|
||||
"valeur": 2750.00
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"instrument_id": 2,
|
||||
"quantite": 5.0,
|
||||
"pru": 350.00,
|
||||
"prix_cloture": 360.00,
|
||||
"valeur": 1800.00
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /accounts/{id}/snapshots/recompute`
|
||||
|
||||
Recalcule immédiatement les snapshots d'un compte depuis sa **date d'invalidation** jusqu'à `today + horizon`.
|
||||
|
||||
Chaque mutation de transaction (create, update, delete, validate/dévalider) marque automatiquement les comptes concernés comme dirty avec `recompute_from = MIN(date_existante, date_transaction)`. Cet endpoint consomme ce flag sans attendre le job nocturne.
|
||||
|
||||
**Réponse `200`** — recalcul effectué
|
||||
```json
|
||||
{ "account_id": 1, "from": "2026-06-01", "to": "2026-07-13", "status": "ok" }
|
||||
```
|
||||
|
||||
**Réponse `204`** — aucune invalidation en attente, rien à faire
|
||||
**Réponse `404`** — compte introuvable
|
||||
|
|
|
|||
41
internal/auth/auth.go
Normal file
41
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
func WithOwner(ctx context.Context, ownerID int32) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, ownerID)
|
||||
}
|
||||
|
||||
func OwnerFromContext(ctx context.Context) (int32, bool) {
|
||||
id, ok := ctx.Value(contextKey{}).(int32)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// Middleware extrait X-Owner-ID du header et l'injecte dans le contexte.
|
||||
// Pour le dev, c'est l'ID numérique de l'owner. À remplacer par JWT en prod.
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
v := r.Header.Get("X-Owner-ID")
|
||||
if v == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintln(w, `{"error":"missing X-Owner-ID header"}`)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(v)
|
||||
if err != nil || id <= 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintln(w, `{"error":"invalid X-Owner-ID"}`)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithOwner(r.Context(), int32(id))))
|
||||
})
|
||||
}
|
||||
|
|
@ -2,17 +2,19 @@ package config
|
|||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
Port string
|
||||
Env string
|
||||
OpenFIGIKey string
|
||||
CoinGeckoKey string
|
||||
PriceFetchInterval time.Duration
|
||||
PriceCleanInterval time.Duration
|
||||
DatabaseURL string
|
||||
Port string
|
||||
Env string
|
||||
OpenFIGIKey string
|
||||
CoinGeckoKey string
|
||||
PriceFetchInterval time.Duration
|
||||
PriceCleanInterval time.Duration
|
||||
SnapshotHorizonDays int // nombre de jours dans le futur couverts par les snapshots
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
|
|
@ -21,14 +23,20 @@ func Load() *Config {
|
|||
fetchInterval = time.Hour
|
||||
}
|
||||
|
||||
horizonDays, err := strconv.Atoi(getenv("SNAPSHOT_HORIZON_DAYS", "30"))
|
||||
if err != nil || horizonDays < 0 {
|
||||
horizonDays = 30
|
||||
}
|
||||
|
||||
return &Config{
|
||||
DatabaseURL: getenv("DATABASE_URL", "postgres://account:account@localhost:5432/account?sslmode=disable"),
|
||||
Port: getenv("PORT", "8080"),
|
||||
Env: getenv("ENV", "development"),
|
||||
OpenFIGIKey: os.Getenv("OPENFIGI_API_KEY"),
|
||||
CoinGeckoKey: os.Getenv("COINGECKO_API_KEY"),
|
||||
PriceFetchInterval: fetchInterval,
|
||||
PriceCleanInterval: 24 * time.Hour,
|
||||
DatabaseURL: getenv("DATABASE_URL", "postgres://account:account@localhost:5432/account?sslmode=disable"),
|
||||
Port: getenv("PORT", "8080"),
|
||||
Env: getenv("ENV", "development"),
|
||||
OpenFIGIKey: os.Getenv("OPENFIGI_API_KEY"),
|
||||
CoinGeckoKey: os.Getenv("COINGECKO_API_KEY"),
|
||||
PriceFetchInterval: fetchInterval,
|
||||
PriceCleanInterval: 24 * time.Hour,
|
||||
SnapshotHorizonDays: horizonDays,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ func (h *AccountHandler) RegisterRoutes(mux *http.ServeMux) {
|
|||
// --- Comptes maîtres ---
|
||||
|
||||
func (h *AccountHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
accounts, err := h.store.ListAccounts(r.Context())
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
accounts, err := h.store.ListAccounts(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list accounts")
|
||||
return
|
||||
|
|
@ -44,11 +48,22 @@ func (h *AccountHandler) list(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *AccountHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.VerifyAccountOwner(r.Context(), id, ownerID); errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "account not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get account")
|
||||
return
|
||||
}
|
||||
account, err := h.store.GetAccountWithEnvelopes(r.Context(), id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "account not found")
|
||||
|
|
@ -62,6 +77,10 @@ func (h *AccountHandler) get(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *AccountHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p store.CreateAccountParams
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
|
|
@ -71,7 +90,7 @@ func (h *AccountHandler) create(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "nom and type are required")
|
||||
return
|
||||
}
|
||||
account, err := h.store.CreateAccount(r.Context(), p)
|
||||
account, err := h.store.CreateAccount(r.Context(), p, ownerID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create account")
|
||||
return
|
||||
|
|
@ -80,6 +99,10 @@ func (h *AccountHandler) create(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *AccountHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
|
|
@ -90,7 +113,7 @@ func (h *AccountHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
account, err := h.store.UpdateAccount(r.Context(), id, p)
|
||||
account, err := h.store.UpdateAccount(r.Context(), id, p, ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "account not found")
|
||||
return
|
||||
|
|
@ -103,12 +126,16 @@ func (h *AccountHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *AccountHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteAccount(r.Context(), id); err != nil {
|
||||
if err := h.store.DeleteAccount(r.Context(), id, ownerID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete account")
|
||||
return
|
||||
}
|
||||
|
|
@ -118,11 +145,22 @@ func (h *AccountHandler) delete(w http.ResponseWriter, r *http.Request) {
|
|||
// --- Enveloppes ---
|
||||
|
||||
func (h *AccountHandler) listEnvelopes(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.VerifyAccountOwner(r.Context(), id, ownerID); errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "account not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to verify account")
|
||||
return
|
||||
}
|
||||
envelopes, err := h.store.ListEnvelopes(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list envelopes")
|
||||
|
|
@ -132,6 +170,10 @@ func (h *AccountHandler) listEnvelopes(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *AccountHandler) createEnvelope(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
masterID, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
|
|
@ -146,7 +188,7 @@ func (h *AccountHandler) createEnvelope(w http.ResponseWriter, r *http.Request)
|
|||
writeError(w, http.StatusBadRequest, "nom is required")
|
||||
return
|
||||
}
|
||||
envelope, err := h.store.CreateEnvelope(r.Context(), masterID, p)
|
||||
envelope, err := h.store.CreateEnvelope(r.Context(), masterID, p, ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "account not found")
|
||||
return
|
||||
|
|
@ -163,6 +205,10 @@ func (h *AccountHandler) createEnvelope(w http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
|
||||
func (h *AccountHandler) updateEnvelope(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
|
|
@ -173,7 +219,7 @@ func (h *AccountHandler) updateEnvelope(w http.ResponseWriter, r *http.Request)
|
|||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
envelope, err := h.store.UpdateEnvelope(r.Context(), id, p)
|
||||
envelope, err := h.store.UpdateEnvelope(r.Context(), id, p, ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "envelope not found")
|
||||
return
|
||||
|
|
@ -186,12 +232,16 @@ func (h *AccountHandler) updateEnvelope(w http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
|
||||
func (h *AccountHandler) deleteEnvelope(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteAccount(r.Context(), id); err != nil {
|
||||
if err := h.store.DeleteAccount(r.Context(), id, ownerID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete envelope")
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package handler
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.g3e.fr/H6N/account/internal/auth"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
|
@ -14,3 +16,13 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// requireOwner extrait l'owner du contexte ou écrit 401 et retourne false.
|
||||
func requireOwner(w http.ResponseWriter, r *http.Request) (int32, bool) {
|
||||
id, ok := auth.OwnerFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing owner")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
|
|
|||
173
internal/handler/snapshot.go
Normal file
173
internal/handler/snapshot.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.g3e.fr/H6N/account/internal/snapshot"
|
||||
"git.g3e.fr/H6N/account/internal/store"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (h *SnapshotHandler) verifyAccountOwner(w http.ResponseWriter, r *http.Request) (int32, int32, bool) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
accountID, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid account id")
|
||||
return 0, 0, false
|
||||
}
|
||||
if err := h.engine.Store().VerifyAccountOwner(r.Context(), accountID, ownerID); errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "account not found")
|
||||
return 0, 0, false
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to verify account")
|
||||
return 0, 0, false
|
||||
}
|
||||
return ownerID, accountID, true
|
||||
}
|
||||
|
||||
type SnapshotHandler struct {
|
||||
engine *snapshot.Engine
|
||||
}
|
||||
|
||||
func NewSnapshotHandler(e *snapshot.Engine) *SnapshotHandler {
|
||||
return &SnapshotHandler{engine: e}
|
||||
}
|
||||
|
||||
func (h *SnapshotHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /accounts/{id}/snapshots", h.listAccountSnapshots)
|
||||
mux.HandleFunc("GET /accounts/{id}/snapshots/positions", h.listPositionSnapshots)
|
||||
mux.HandleFunc("POST /accounts/{id}/snapshots/recompute", h.recomputeAccount)
|
||||
mux.HandleFunc("GET /snapshots/pending", h.listPending)
|
||||
}
|
||||
|
||||
// parseDateParam parse un paramètre de query YYYY-MM-DD. Retourne time.Time{} si absent.
|
||||
func parseDateParam(r *http.Request, key string) (time.Time, error) {
|
||||
v := r.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return time.Parse("2006-01-02", v)
|
||||
}
|
||||
|
||||
// GET /snapshots/pending
|
||||
// Liste les comptes de l'owner ayant une invalidation en attente.
|
||||
func (h *SnapshotHandler) listPending(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
invalidations, err := h.engine.Store().GetInvalidationsForOwner(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type row struct {
|
||||
AccountID int32 `json:"account_id"`
|
||||
RecomputeFrom string `json:"recompute_from"`
|
||||
}
|
||||
out := make([]row, 0, len(invalidations))
|
||||
for _, inv := range invalidations {
|
||||
out = append(out, row{
|
||||
AccountID: inv.AccountID,
|
||||
RecomputeFrom: inv.RecomputeFrom.Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GET /accounts/{id}/snapshots?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
// Liste les snapshots agrégés (valeur totale) d'un compte.
|
||||
// from/to optionnels : sans borne → pas de filtre de ce côté.
|
||||
func (h *SnapshotHandler) listAccountSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
_, accountID, ok := h.verifyAccountOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
from, err := parseDateParam(r, "from")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid from date")
|
||||
return
|
||||
}
|
||||
to, err := parseDateParam(r, "to")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid to date")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.engine.Store().ListAccountSnapshots(r.Context(), accountID, from, to)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []store.AccountSnapshotRow{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /accounts/{id}/snapshots/positions?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
// Liste les positions détaillées (par instrument) d'un compte.
|
||||
func (h *SnapshotHandler) listPositionSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
_, accountID, ok := h.verifyAccountOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
from, err := parseDateParam(r, "from")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid from date")
|
||||
return
|
||||
}
|
||||
to, err := parseDateParam(r, "to")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid to date")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.engine.Store().ListPositionSnapshots(r.Context(), accountID, from, to)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []store.PositionSnapshotRow{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /accounts/{id}/snapshots/recompute
|
||||
// Recalcule les snapshots du compte depuis sa date d'invalidation jusqu'à aujourd'hui.
|
||||
// Retourne 204 si aucune invalidation n'est en attente.
|
||||
func (h *SnapshotHandler) recomputeAccount(w http.ResponseWriter, r *http.Request) {
|
||||
_, accountID, ok := h.verifyAccountOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
from, to, err := h.engine.RecomputeAccount(r.Context(), accountID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if from.IsZero() {
|
||||
w.WriteHeader(http.StatusNoContent) // aucune invalidation en attente
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"account_id": accountID,
|
||||
"from": from.Format("2006-01-02"),
|
||||
"to": to.Format("2006-01-02"),
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
|
@ -28,11 +28,22 @@ func (h *TransactionHandler) RegisterRoutes(mux *http.ServeMux) {
|
|||
}
|
||||
|
||||
func (h *TransactionHandler) listForAccount(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
accountID, err := parseID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
if err := h.store.VerifyAccountOwner(r.Context(), accountID, ownerID); errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "account not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to verify account")
|
||||
return
|
||||
}
|
||||
f := store.AccountTransactionFilters{
|
||||
Validated: parseBoolParam(r, "validated"),
|
||||
Pending: parseBoolParam(r, "pending"),
|
||||
|
|
@ -48,12 +59,16 @@ func (h *TransactionHandler) listForAccount(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
|
||||
func (h *TransactionHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseTxID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
tx, err := h.store.GetTransaction(r.Context(), id)
|
||||
tx, err := h.store.GetTransaction(r.Context(), id, ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "transaction not found")
|
||||
return
|
||||
|
|
@ -66,6 +81,10 @@ func (h *TransactionHandler) get(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *TransactionHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p store.CreateTransactionParams
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
|
|
@ -79,6 +98,25 @@ func (h *TransactionHandler) create(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "date is required")
|
||||
return
|
||||
}
|
||||
// Vérifier que les comptes référencés appartiennent à l'owner.
|
||||
if p.AccountSourceID != nil {
|
||||
if err := h.store.VerifyAccountOwner(r.Context(), *p.AccountSourceID, ownerID); errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "source account not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to verify source account")
|
||||
return
|
||||
}
|
||||
}
|
||||
if p.AccountDestID != nil {
|
||||
if err := h.store.VerifyAccountOwner(r.Context(), *p.AccountDestID, ownerID); errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "dest account not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to verify dest account")
|
||||
return
|
||||
}
|
||||
}
|
||||
tx, err := h.store.CreateTransaction(r.Context(), p)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create transaction")
|
||||
|
|
@ -88,6 +126,10 @@ func (h *TransactionHandler) create(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *TransactionHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseTxID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
|
|
@ -102,7 +144,7 @@ func (h *TransactionHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "label is required")
|
||||
return
|
||||
}
|
||||
tx, err := h.store.UpdateTransaction(r.Context(), id, p)
|
||||
tx, err := h.store.UpdateTransaction(r.Context(), id, p, ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "transaction not found")
|
||||
return
|
||||
|
|
@ -115,12 +157,19 @@ func (h *TransactionHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *TransactionHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseTxID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteTransaction(r.Context(), id); err != nil {
|
||||
if err := h.store.DeleteTransaction(r.Context(), id, ownerID); errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "transaction not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete transaction")
|
||||
return
|
||||
}
|
||||
|
|
@ -128,6 +177,10 @@ func (h *TransactionHandler) delete(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *TransactionHandler) validate(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := requireOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseTxID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
|
|
@ -140,7 +193,7 @@ func (h *TransactionHandler) validate(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
tx, err := h.store.SetValidated(r.Context(), id, body.Validated)
|
||||
tx, err := h.store.SetValidated(r.Context(), id, body.Validated, ownerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "transaction not found")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResolveCryptoID résout un ticker crypto (ex: "BTC") en ID CoinGecko (ex: "bitcoin").
|
||||
|
|
@ -46,6 +47,48 @@ func ResolveCryptoID(ctx context.Context, apiKey, ticker string) (string, error)
|
|||
return "", fmt.Errorf("no coingecko match for ticker %s", ticker)
|
||||
}
|
||||
|
||||
// FetchCoinGeckoHistory récupère les prix EUR journaliers entre from et to pour un coin.
|
||||
func FetchCoinGeckoHistory(ctx context.Context, apiKey, coinID string, from, to time.Time) ([]PricePoint, error) {
|
||||
baseURL := "https://api.coingecko.com/api/v3"
|
||||
if apiKey != "" {
|
||||
baseURL = "https://pro-api.coingecko.com/api/v3"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/coins/%s/market_chart/range?vs_currency=eur&from=%d&to=%d",
|
||||
baseURL, coinID, from.Unix(), to.Unix())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiKey != "" {
|
||||
req.Header.Set("x-cg-pro-api-key", apiKey)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Réponse : {"prices": [[timestamp_ms, price], ...]}
|
||||
var raw struct {
|
||||
Prices [][2]float64 `json:"prices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decode coingecko history: %w", err)
|
||||
}
|
||||
|
||||
points := make([]PricePoint, 0, len(raw.Prices))
|
||||
for _, p := range raw.Prices {
|
||||
points = append(points, PricePoint{
|
||||
At: time.UnixMilli(int64(p[0])).UTC(),
|
||||
Price: p[1],
|
||||
})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// FetchCoinGeckoPrices récupère les prix EUR pour une liste d'IDs CoinGecko.
|
||||
// Le ticker stocké dans instrument_ticker_cache doit être l'ID CoinGecko (ex: "bitcoin", "ethereum").
|
||||
func FetchCoinGeckoPrices(ctx context.Context, apiKey string, coinIDs []string) (map[string]float64, error) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package pipeline
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
|
|
@ -90,6 +91,54 @@ func (p *Pipeline) FetchAll(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// BackfillInstrument récupère l'historique de prix d'un instrument entre from et to
|
||||
// et upserte chaque point dans price_history. Idempotent.
|
||||
func (p *Pipeline) BackfillInstrument(ctx context.Context, instrumentID int32, from, to time.Time) error {
|
||||
inst, err := p.store.GetInstrument(ctx, instrumentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get instrument %d: %w", instrumentID, err)
|
||||
}
|
||||
|
||||
switch inst.Type {
|
||||
case "devise":
|
||||
return nil // EUR toujours = 1, pas de backfill nécessaire
|
||||
|
||||
case "action", "etf":
|
||||
ticker, err := p.resolveTicker(ctx, inst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve ticker for %s: %w", inst.Code, err)
|
||||
}
|
||||
points, err := FetchYahooHistory(ctx, ticker, from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("yahoo history %s: %w", ticker, err)
|
||||
}
|
||||
for _, pt := range points {
|
||||
if err := p.store.UpsertPrice(ctx, inst.ID, pt.At, pt.Price); err != nil {
|
||||
p.logger.Error("upsert historical price", "instrument", inst.Code, "date", pt.At, "error", err)
|
||||
}
|
||||
}
|
||||
p.logger.Info("backfill done", "instrument", inst.Code, "points", len(points))
|
||||
|
||||
case "crypto":
|
||||
coinID, err := p.resolveCoinID(ctx, inst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve coin id for %s: %w", inst.Code, err)
|
||||
}
|
||||
points, err := FetchCoinGeckoHistory(ctx, p.cfg.CoinGeckoKey, coinID, from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("coingecko history %s: %w", coinID, err)
|
||||
}
|
||||
for _, pt := range points {
|
||||
if err := p.store.UpsertPrice(ctx, inst.ID, pt.At, pt.Price); err != nil {
|
||||
p.logger.Error("upsert historical price", "instrument", inst.Code, "date", pt.At, "error", err)
|
||||
}
|
||||
}
|
||||
p.logger.Info("backfill done", "instrument", inst.Code, "points", len(points))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanHistory supprime les prix intraday des jours passés, ne gardant que le dernier par instrument.
|
||||
func (p *Pipeline) CleanHistory(ctx context.Context) error {
|
||||
if err := p.store.CleanPastDays(ctx); err != nil {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type yahooChart struct {
|
||||
|
|
@ -14,6 +15,12 @@ type yahooChart struct {
|
|||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"meta"`
|
||||
Timestamps []int64 `json:"timestamp"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
Close []*float64 `json:"close"`
|
||||
} `json:"quote"`
|
||||
} `json:"indicators"`
|
||||
} `json:"result"`
|
||||
Error *struct{ Description string } `json:"error"`
|
||||
} `json:"chart"`
|
||||
|
|
@ -23,33 +30,79 @@ type yahooChart struct {
|
|||
func FetchYahooPrice(ctx context.Context, ticker string) (float64, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://query1.finance.yahoo.com/v8/finance/chart/%s?interval=1d&range=1d", ticker)
|
||||
return fetchYahoo(ctx, url, ticker)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
// FetchYahooHistory récupère les prix de clôture journaliers entre from et to.
|
||||
// Retourne une slice de (time, price) pour chaque jour ayant une clôture non nulle.
|
||||
func FetchYahooHistory(ctx context.Context, ticker string, from, to time.Time) ([]PricePoint, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://query1.finance.yahoo.com/v8/finance/chart/%s?interval=1d&period1=%d&period2=%d",
|
||||
ticker, from.Unix(), to.Unix())
|
||||
|
||||
chart, err := fetchYahooChart(ctx, url, ticker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(chart.Chart.Result) == 0 || len(chart.Chart.Result[0].Indicators.Quote) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
res := chart.Chart.Result[0]
|
||||
closes := res.Indicators.Quote[0].Close
|
||||
var points []PricePoint
|
||||
for i, ts := range res.Timestamps {
|
||||
if i >= len(closes) || closes[i] == nil || *closes[i] == 0 {
|
||||
continue
|
||||
}
|
||||
points = append(points, PricePoint{
|
||||
At: time.Unix(ts, 0).UTC(),
|
||||
Price: *closes[i],
|
||||
})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func fetchYahoo(ctx context.Context, url, ticker string) (float64, error) {
|
||||
chart, err := fetchYahooChart(ctx, url, ticker)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var chart yahooChart
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chart); err != nil {
|
||||
return 0, fmt.Errorf("decode yahoo response: %w", err)
|
||||
}
|
||||
if chart.Chart.Error != nil {
|
||||
return 0, fmt.Errorf("yahoo error: %s", chart.Chart.Error.Description)
|
||||
}
|
||||
if len(chart.Chart.Result) == 0 {
|
||||
return 0, fmt.Errorf("no result for ticker %s", ticker)
|
||||
}
|
||||
|
||||
price := chart.Chart.Result[0].Meta.RegularMarketPrice
|
||||
if price == 0 {
|
||||
return 0, fmt.Errorf("zero price for ticker %s", ticker)
|
||||
}
|
||||
return price, nil
|
||||
}
|
||||
|
||||
func fetchYahooChart(ctx context.Context, url, ticker string) (*yahooChart, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var chart yahooChart
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chart); err != nil {
|
||||
return nil, fmt.Errorf("decode yahoo response: %w", err)
|
||||
}
|
||||
if chart.Chart.Error != nil {
|
||||
return nil, fmt.Errorf("yahoo error: %s", chart.Chart.Error.Description)
|
||||
}
|
||||
return &chart, nil
|
||||
}
|
||||
|
||||
// PricePoint est un prix à un instant donné, partagé entre les providers.
|
||||
type PricePoint struct {
|
||||
At time.Time
|
||||
Price float64
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.g3e.fr/H6N/account/internal/auth"
|
||||
"git.g3e.fr/H6N/account/internal/config"
|
||||
"git.g3e.fr/H6N/account/internal/handler"
|
||||
"git.g3e.fr/H6N/account/internal/pipeline"
|
||||
"git.g3e.fr/H6N/account/internal/snapshot"
|
||||
"git.g3e.fr/H6N/account/internal/store"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
|
@ -30,15 +33,32 @@ func New(cfg *config.Config, pool *pgxpool.Pool, logger *slog.Logger) *Server {
|
|||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
// /health est exempt d'auth. Toutes les autres routes passent par le middleware owner.
|
||||
if r.URL.Path == "/health" {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
auth.Middleware(s.mux).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
st := store.New(s.pool)
|
||||
horizonDays := 30
|
||||
var pipelineCfg pipeline.Config
|
||||
if s.cfg != nil {
|
||||
horizonDays = s.cfg.SnapshotHorizonDays
|
||||
pipelineCfg = pipeline.Config{
|
||||
OpenFIGIKey: s.cfg.OpenFIGIKey,
|
||||
CoinGeckoKey: s.cfg.CoinGeckoKey,
|
||||
}
|
||||
}
|
||||
pl := pipeline.New(st, pipelineCfg, s.logger)
|
||||
eng := snapshot.New(st, s.logger, horizonDays).WithBackfiller(pl)
|
||||
|
||||
handler.NewInstrumentHandler(st).RegisterRoutes(s.mux)
|
||||
handler.NewAccountHandler(st).RegisterRoutes(s.mux)
|
||||
handler.NewTransactionHandler(st).RegisterRoutes(s.mux)
|
||||
handler.NewSnapshotHandler(eng).RegisterRoutes(s.mux)
|
||||
|
||||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||
}
|
||||
|
|
|
|||
259
internal/snapshot/engine.go
Normal file
259
internal/snapshot/engine.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.g3e.fr/H6N/account/internal/store"
|
||||
)
|
||||
|
||||
// PriceBackfiller est implémenté par le pipeline pour récupérer l'historique de prix manquant.
|
||||
type PriceBackfiller interface {
|
||||
BackfillInstrument(ctx context.Context, instrumentID int32, from, to time.Time) error
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
horizonDays int
|
||||
backfiller PriceBackfiller
|
||||
}
|
||||
|
||||
func New(st *store.Store, logger *slog.Logger, horizonDays int) *Engine {
|
||||
return &Engine{store: st, logger: logger, horizonDays: horizonDays}
|
||||
}
|
||||
|
||||
// WithBackfiller active le backfill automatique des prix historiques manquants.
|
||||
func (e *Engine) WithBackfiller(b PriceBackfiller) *Engine {
|
||||
e.backfiller = b
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Engine) Store() *store.Store { return e.store }
|
||||
|
||||
// RecomputeDay calcule les snapshots de tous les comptes pour une date donnée.
|
||||
// Toutes les transactions (validées ou non) sont incluses.
|
||||
func (e *Engine) RecomputeDay(ctx context.Context, date time.Time) error {
|
||||
date = date.Truncate(24 * time.Hour)
|
||||
|
||||
accountIDs, err := e.store.ListAllAccountIDs(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
|
||||
for _, accountID := range accountIDs {
|
||||
if err := e.recomputeAccount(ctx, accountID, date); err != nil {
|
||||
e.logger.Error("snapshot: account failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
e.logger.Info("snapshot: day computed", "date", date.Format("2006-01-02"), "accounts", len(accountIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecomputeAccount recalcule les snapshots d'un compte depuis son invalidation
|
||||
// jusqu'à aujourd'hui + horizon. Efface l'invalidation une fois terminé.
|
||||
// Retourne (zero, zero, nil) si aucune invalidation n'est en attente.
|
||||
func (e *Engine) RecomputeAccount(ctx context.Context, accountID int32) (from, to time.Time, err error) {
|
||||
inv, found, err := e.store.GetInvalidation(ctx, accountID)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("get invalidation: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
horizon := today.AddDate(0, 0, e.horizonDays)
|
||||
|
||||
if err := e.backfillAccount(ctx, accountID, inv.RecomputeFrom, horizon); err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
|
||||
if err := e.store.ClearInvalidation(ctx, accountID); err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("clear invalidation: %w", err)
|
||||
}
|
||||
|
||||
return inv.RecomputeFrom, horizon, nil
|
||||
}
|
||||
|
||||
// DailySnapshot :
|
||||
// 1. Traite les invalidations en attente (jusqu'à today + horizon)
|
||||
// 2. Recalcule hier (consolide les transactions de la veille)
|
||||
// 3. Calcule le nouveau jour entrant dans la fenêtre (today + horizonDays)
|
||||
func (e *Engine) DailySnapshot(ctx context.Context) error {
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
newHorizonDay := today.AddDate(0, 0, e.horizonDays)
|
||||
|
||||
// 1. Invalidations
|
||||
if err := e.processInvalidations(ctx, newHorizonDay); err != nil {
|
||||
e.logger.Error("snapshot: invalidation processing failed", "error", err)
|
||||
}
|
||||
|
||||
// 2. Hier
|
||||
if err := e.RecomputeDay(ctx, yesterday); err != nil {
|
||||
e.logger.Error("snapshot: yesterday recompute failed", "error", err)
|
||||
}
|
||||
|
||||
// 3. Nouveau jour entrant dans la fenêtre
|
||||
if err := e.RecomputeDay(ctx, newHorizonDay); err != nil {
|
||||
e.logger.Error("snapshot: horizon day failed", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) processInvalidations(ctx context.Context, until time.Time) error {
|
||||
invalidations, err := e.store.GetInvalidations(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get invalidations: %w", err)
|
||||
}
|
||||
if len(invalidations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
e.logger.Info("snapshot: processing invalidations", "count", len(invalidations))
|
||||
|
||||
for _, inv := range invalidations {
|
||||
if err := e.backfillAccount(ctx, inv.AccountID, inv.RecomputeFrom, until); err != nil {
|
||||
e.logger.Error("snapshot: backfill failed", "account_id", inv.AccountID, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := e.store.ClearInvalidation(ctx, inv.AccountID); err != nil {
|
||||
e.logger.Error("snapshot: clear invalidation failed", "account_id", inv.AccountID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) backfillAccount(ctx context.Context, accountID int32, from, to time.Time) error {
|
||||
from = from.Truncate(24 * time.Hour)
|
||||
to = to.Truncate(24 * time.Hour)
|
||||
|
||||
if from.After(to) {
|
||||
return fmt.Errorf("from (%s) after to (%s)", from.Format("2006-01-02"), to.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// backfilledInstruments évite de re-fetcher le même instrument plusieurs fois dans ce run.
|
||||
backfilledInstruments := make(map[int32]bool)
|
||||
|
||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
||||
if err := e.recomputeAccountWithBackfill(ctx, accountID, d, backfilledInstruments); err != nil {
|
||||
return fmt.Errorf("recompute %s: %w", d.Format("2006-01-02"), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) recomputeAccount(ctx context.Context, accountID int32, date time.Time) error {
|
||||
return e.recomputeAccountWithBackfill(ctx, accountID, date, nil)
|
||||
}
|
||||
|
||||
func (e *Engine) recomputeAccountWithBackfill(ctx context.Context, accountID int32, date time.Time, backfilled map[int32]bool) error {
|
||||
baseDate, hasBase, err := e.store.GetLatestSnapshotDate(ctx, accountID, date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get latest snapshot date: %w", err)
|
||||
}
|
||||
|
||||
prus, err := e.store.ComputePRUs(ctx, accountID, date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute PRUs: %w", err)
|
||||
}
|
||||
|
||||
positions := map[int32]store.PositionRow{}
|
||||
if hasBase {
|
||||
base, err := e.store.GetBasePositions(ctx, accountID, baseDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get base positions: %w", err)
|
||||
}
|
||||
for _, p := range base {
|
||||
positions[p.InstrumentID] = p
|
||||
}
|
||||
}
|
||||
|
||||
deltas, err := e.store.ComputeDeltas(ctx, accountID, baseDate, date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute deltas: %w", err)
|
||||
}
|
||||
|
||||
for _, d := range deltas {
|
||||
p := positions[d.InstrumentID]
|
||||
p.InstrumentID = d.InstrumentID
|
||||
p.InstrumentType = d.InstrumentType
|
||||
p.Quantite += d.Quantite
|
||||
positions[d.InstrumentID] = p
|
||||
}
|
||||
|
||||
var totalValeur float64
|
||||
|
||||
for _, pos := range positions {
|
||||
if pos.Quantite == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
prix, found, err := e.store.GetPriceAt(ctx, pos.InstrumentID, date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get price instrument %d: %w", pos.InstrumentID, err)
|
||||
}
|
||||
if !found && pos.InstrumentType == "devise" {
|
||||
prix = 1.0
|
||||
found = true
|
||||
}
|
||||
|
||||
// Backfill automatique si prix manquant et backfiller disponible.
|
||||
// from = date - 1 jour (marge timezone), to = maintenant (inclut aujourd'hui).
|
||||
if !found && e.backfiller != nil && (backfilled == nil || !backfilled[pos.InstrumentID]) {
|
||||
from := date.AddDate(0, 0, -1)
|
||||
now := time.Now().UTC()
|
||||
if err := e.backfiller.BackfillInstrument(ctx, pos.InstrumentID, from, now); err != nil {
|
||||
e.logger.Warn("snapshot: backfill échoué",
|
||||
"instrument_id", pos.InstrumentID, "error", err)
|
||||
} else {
|
||||
if backfilled != nil {
|
||||
backfilled[pos.InstrumentID] = true
|
||||
}
|
||||
// Retry après backfill
|
||||
prix, found, err = e.store.GetPriceAt(ctx, pos.InstrumentID, date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get price instrument %d (post-backfill): %w", pos.InstrumentID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snap := store.PositionSnapshot{
|
||||
Date: date,
|
||||
AccountID: accountID,
|
||||
InstrumentID: pos.InstrumentID,
|
||||
Quantite: pos.Quantite,
|
||||
}
|
||||
if pru, ok := prus[pos.InstrumentID]; ok {
|
||||
snap.PRU = &pru
|
||||
}
|
||||
if found {
|
||||
valeur := pos.Quantite * prix
|
||||
snap.PrixCloture = &prix
|
||||
snap.Valeur = &valeur
|
||||
totalValeur += valeur
|
||||
} else {
|
||||
e.logger.Warn("snapshot: no price, valeur non calculée",
|
||||
"account_id", accountID,
|
||||
"instrument_id", pos.InstrumentID,
|
||||
"date", date.Format("2006-01-02"),
|
||||
)
|
||||
}
|
||||
|
||||
if err := e.store.UpsertPositionSnapshot(ctx, snap); err != nil {
|
||||
return fmt.Errorf("upsert position snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return e.store.UpsertAccountSnapshot(ctx, store.AccountSnapshot{
|
||||
Date: date,
|
||||
AccountID: accountID,
|
||||
Valeur: totalValeur,
|
||||
})
|
||||
}
|
||||
|
|
@ -8,13 +8,14 @@ import (
|
|||
)
|
||||
|
||||
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"`
|
||||
MasterAccountID *int32 `json:"master_account_id,omitempty"`
|
||||
Objectif *string `json:"objectif,omitempty"`
|
||||
OwnerID int32 `json:"-"`
|
||||
}
|
||||
|
||||
type CreateAccountParams struct {
|
||||
|
|
@ -31,11 +32,27 @@ type CreateEnvelopeParams struct {
|
|||
|
||||
var ErrMasterIsSubAccount = errors.New("master account cannot itself be a sub-account")
|
||||
|
||||
const accountCols = `id, nom, type, devise_reference, plafond, master_account_id, objectif`
|
||||
const accountCols = `id, nom, type, devise_reference, plafond, master_account_id, objectif, owner_id`
|
||||
|
||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
// VerifyAccountOwner retourne pgx.ErrNoRows si le compte n'appartient pas à ownerID.
|
||||
func (s *Store) VerifyAccountOwner(ctx context.Context, accountID, ownerID int32) error {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM account WHERE id = $1 AND owner_id = $2)`,
|
||||
accountID, ownerID).Scan(&exists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAccounts(ctx context.Context, ownerID int32) ([]Account, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT `+accountCols+` FROM account WHERE master_account_id IS NULL ORDER BY nom`)
|
||||
`SELECT `+accountCols+` FROM account WHERE master_account_id IS NULL AND owner_id = $1 ORDER BY nom`,
|
||||
ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -80,22 +97,22 @@ func (s *Store) GetAccountWithEnvelopes(ctx context.Context, id int32) (AccountW
|
|||
return AccountWithEnvelopes{Account: account, Envelopes: envelopes}, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAccount(ctx context.Context, p CreateAccountParams) (Account, error) {
|
||||
func (s *Store) CreateAccount(ctx context.Context, p CreateAccountParams, ownerID int32) (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)
|
||||
`INSERT INTO account (nom, type, devise_reference, plafond, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING `+accountCols,
|
||||
p.Nom, p.Type, p.DeviseReference, p.Plafond)
|
||||
p.Nom, p.Type, p.DeviseReference, p.Plafond, ownerID)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account])
|
||||
}
|
||||
|
||||
func (s *Store) CreateEnvelope(ctx context.Context, masterID int32, p CreateEnvelopeParams) (Account, error) {
|
||||
func (s *Store) CreateEnvelope(ctx context.Context, masterID int32, p CreateEnvelopeParams, ownerID int32) (Account, error) {
|
||||
master, err := s.GetAccount(ctx, masterID)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
|
|
@ -103,45 +120,48 @@ func (s *Store) CreateEnvelope(ctx context.Context, masterID int32, p CreateEnve
|
|||
if master.MasterAccountID != nil {
|
||||
return Account{}, ErrMasterIsSubAccount
|
||||
}
|
||||
if master.OwnerID != ownerID {
|
||||
return Account{}, pgx.ErrNoRows
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`INSERT INTO account (nom, type, devise_reference, master_account_id, objectif)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`INSERT INTO account (nom, type, devise_reference, master_account_id, objectif, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING `+accountCols,
|
||||
p.Nom, master.Type, master.DeviseReference, masterID, p.Objectif)
|
||||
p.Nom, master.Type, master.DeviseReference, masterID, p.Objectif, ownerID)
|
||||
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) {
|
||||
func (s *Store) UpdateAccount(ctx context.Context, id int32, p CreateAccountParams, ownerID int32) (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 AND master_account_id IS NULL
|
||||
WHERE id = $1 AND master_account_id IS NULL AND owner_id = $6
|
||||
RETURNING `+accountCols,
|
||||
id, p.Nom, p.Type, p.DeviseReference, p.Plafond)
|
||||
id, p.Nom, p.Type, p.DeviseReference, p.Plafond, ownerID)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account])
|
||||
}
|
||||
|
||||
func (s *Store) UpdateEnvelope(ctx context.Context, id int32, p CreateEnvelopeParams) (Account, error) {
|
||||
func (s *Store) UpdateEnvelope(ctx context.Context, id int32, p CreateEnvelopeParams, ownerID int32) (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
|
||||
WHERE id = $1 AND master_account_id IS NOT NULL AND owner_id = $4
|
||||
RETURNING `+accountCols,
|
||||
id, p.Nom, p.Objectif)
|
||||
id, p.Nom, p.Objectif, ownerID)
|
||||
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)
|
||||
func (s *Store) DeleteAccount(ctx context.Context, id int32, ownerID int32) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM account WHERE id = $1 AND owner_id = $2`, id, ownerID)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
326
internal/store/snapshot.go
Normal file
326
internal/store/snapshot.go
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type PositionRow struct {
|
||||
InstrumentID int32 `json:"instrument_id"`
|
||||
InstrumentType string `json:"instrument_type"`
|
||||
Quantite float64 `json:"quantite"`
|
||||
}
|
||||
|
||||
type PositionSnapshot struct {
|
||||
Date time.Time
|
||||
AccountID int32
|
||||
InstrumentID int32
|
||||
Quantite float64
|
||||
PRU *float64
|
||||
PrixCloture *float64
|
||||
Valeur *float64
|
||||
}
|
||||
|
||||
type AccountSnapshot struct {
|
||||
Date time.Time
|
||||
AccountID int32
|
||||
Valeur float64
|
||||
}
|
||||
|
||||
// ListAllAccountIDs retourne tous les comptes (maîtres + enveloppes).
|
||||
func (s *Store) ListAllAccountIDs(ctx context.Context) ([]int32, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id FROM account ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int32
|
||||
for rows.Next() {
|
||||
var id int32
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// GetLatestSnapshotDate retourne la date du snapshot le plus récent strictement
|
||||
// antérieur à before. Retourne (zero, false, nil) si aucun snapshot n'existe.
|
||||
// On utilise account_snapshot comme marqueur de "jour déjà calculé" car il
|
||||
// contient toujours une ligne même pour un compte à solde nul.
|
||||
func (s *Store) GetLatestSnapshotDate(ctx context.Context, accountID int32, before time.Time) (time.Time, bool, error) {
|
||||
var t *time.Time
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT MAX(date) FROM account_snapshot
|
||||
WHERE account_id = $1 AND date < $2::date
|
||||
`, accountID, before).Scan(&t)
|
||||
if err != nil || t == nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return *t, true, nil
|
||||
}
|
||||
|
||||
// GetBasePositions retourne les positions d'un compte à une date de snapshot
|
||||
// existante, enrichies du type d'instrument pour le fallback de prix.
|
||||
func (s *Store) GetBasePositions(ctx context.Context, accountID int32, date time.Time) ([]PositionRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT ps.instrument_id, i.type AS instrument_type, ps.quantite
|
||||
FROM position_snapshot ps
|
||||
JOIN instrument i ON i.id = ps.instrument_id
|
||||
WHERE ps.account_id = $1 AND ps.date = $2::date
|
||||
`, accountID, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pgx.CollectRows(rows, pgx.RowToStructByName[PositionRow])
|
||||
}
|
||||
|
||||
// ComputeDeltas calcule les variations nettes par instrument pour un compte
|
||||
// entre fromExclusive (exclu) et toInclusive (inclus).
|
||||
// Toutes les transactions sont comptées quelle que soit leur validation :
|
||||
// le solde reflète l'état envisagé complet (passé confirmé + prévisionnel).
|
||||
// Passer time.Time{} comme fromExclusive couvre toutes les transactions.
|
||||
func (s *Store) ComputeDeltas(ctx context.Context, accountID int32, fromExclusive, toInclusive time.Time) ([]PositionRow, error) {
|
||||
from := fromExclusive
|
||||
if from.IsZero() {
|
||||
from = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH flows AS (
|
||||
SELECT instrument_dest_id AS instrument_id, quantite_dest AS delta
|
||||
FROM transaction
|
||||
WHERE account_dest_id = $1
|
||||
AND date > $2::date AND date <= $3::date
|
||||
UNION ALL
|
||||
SELECT instrument_source_id, -quantite_source
|
||||
FROM transaction
|
||||
WHERE account_source_id = $1
|
||||
AND date > $2::date AND date <= $3::date
|
||||
)
|
||||
SELECT f.instrument_id, i.type AS instrument_type, SUM(f.delta) AS quantite
|
||||
FROM flows f
|
||||
JOIN instrument i ON i.id = f.instrument_id
|
||||
GROUP BY f.instrument_id, i.type
|
||||
`, accountID, from, toInclusive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pgx.CollectRows(rows, pgx.RowToStructByName[PositionRow])
|
||||
}
|
||||
|
||||
// AccountSnapshotRow représente une ligne de account_snapshot pour la restitution.
|
||||
type AccountSnapshotRow struct {
|
||||
Date string `json:"date"`
|
||||
Valeur float64 `json:"valeur"`
|
||||
}
|
||||
|
||||
// PositionSnapshotRow représente une ligne de position_snapshot pour la restitution.
|
||||
type PositionSnapshotRow struct {
|
||||
Date string `json:"date"`
|
||||
InstrumentID int32 `json:"instrument_id"`
|
||||
Quantite float64 `json:"quantite"`
|
||||
PRU *float64 `json:"pru,omitempty"`
|
||||
PrixCloture *float64 `json:"prix_cloture,omitempty"`
|
||||
Valeur *float64 `json:"valeur,omitempty"`
|
||||
}
|
||||
|
||||
// ListAccountSnapshots retourne les snapshots agrégés d'un compte entre from et to.
|
||||
// Si from est zero, pas de borne inférieure. Si to est zero, pas de borne supérieure.
|
||||
func (s *Store) ListAccountSnapshots(ctx context.Context, accountID int32, from, to time.Time) ([]AccountSnapshotRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT date::text, valeur
|
||||
FROM account_snapshot
|
||||
WHERE account_id = $1
|
||||
AND ($2::date IS NULL OR date >= $2::date)
|
||||
AND ($3::date IS NULL OR date <= $3::date)
|
||||
ORDER BY date
|
||||
`, accountID, nullableDate(from), nullableDate(to))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pgx.CollectRows(rows, pgx.RowToStructByName[AccountSnapshotRow])
|
||||
}
|
||||
|
||||
// ListPositionSnapshots retourne les positions détaillées d'un compte entre from et to.
|
||||
// Si from est zero, pas de borne inférieure. Si to est zero, pas de borne supérieure.
|
||||
func (s *Store) ListPositionSnapshots(ctx context.Context, accountID int32, from, to time.Time) ([]PositionSnapshotRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT date::text, instrument_id, quantite, pru, prix_cloture, valeur
|
||||
FROM position_snapshot
|
||||
WHERE account_id = $1
|
||||
AND ($2::date IS NULL OR date >= $2::date)
|
||||
AND ($3::date IS NULL OR date <= $3::date)
|
||||
ORDER BY date, instrument_id
|
||||
`, accountID, nullableDate(from), nullableDate(to))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pgx.CollectRows(rows, pgx.RowToStructByName[PositionSnapshotRow])
|
||||
}
|
||||
|
||||
// nullableDate convertit time.Time{} en nil pour les paramètres SQL optionnels.
|
||||
func nullableDate(t time.Time) any {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ComputePRUs retourne le PRU (coût moyen pondéré) par instrument pour un compte,
|
||||
// calculé sur toutes les transactions d'achat (account_dest_id) jusqu'à asOf inclus.
|
||||
// PRU = sum(quantite_source) / sum(quantite_dest)
|
||||
// Retourne uniquement les instruments qui ont des achats.
|
||||
func (s *Store) ComputePRUs(ctx context.Context, accountID int32, asOf time.Time) (map[int32]float64, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT instrument_dest_id,
|
||||
SUM(quantite_source) / NULLIF(SUM(quantite_dest), 0) AS pru
|
||||
FROM transaction
|
||||
WHERE account_dest_id = $1
|
||||
AND date <= $2::date
|
||||
AND quantite_source IS NOT NULL
|
||||
GROUP BY instrument_dest_id
|
||||
`, accountID, asOf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int32]float64)
|
||||
for rows.Next() {
|
||||
var instrumentID int32
|
||||
var pru float64
|
||||
if err := rows.Scan(&instrumentID, &pru); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[instrumentID] = pru
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetPriceAt retourne le dernier prix connu pour un instrument à une date donnée.
|
||||
// Retourne (0, false, nil) si aucun prix n'est trouvé.
|
||||
func (s *Store) GetPriceAt(ctx context.Context, instrumentID int32, date time.Time) (float64, bool, error) {
|
||||
var prix float64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT prix FROM price_history
|
||||
WHERE instrument_id = $1 AND fetched_at::date <= $2::date
|
||||
ORDER BY fetched_at DESC
|
||||
LIMIT 1
|
||||
`, instrumentID, date).Scan(&prix)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return prix, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertPositionSnapshot(ctx context.Context, snap PositionSnapshot) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO position_snapshot (date, account_id, instrument_id, quantite, pru, prix_cloture, valeur)
|
||||
VALUES ($1::date, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (date, account_id, instrument_id) DO UPDATE SET
|
||||
quantite = EXCLUDED.quantite,
|
||||
pru = EXCLUDED.pru,
|
||||
prix_cloture = EXCLUDED.prix_cloture,
|
||||
valeur = EXCLUDED.valeur
|
||||
`, snap.Date, snap.AccountID, snap.InstrumentID,
|
||||
snap.Quantite, snap.PRU, snap.PrixCloture, snap.Valeur)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertAccountSnapshot(ctx context.Context, snap AccountSnapshot) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO account_snapshot (date, account_id, valeur)
|
||||
VALUES ($1::date, $2, $3)
|
||||
ON CONFLICT (date, account_id) DO UPDATE SET valeur = EXCLUDED.valeur
|
||||
`, snap.Date, snap.AccountID, snap.Valeur)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Invalidation ─────────────────────────────────────────────────────────────
|
||||
|
||||
type Invalidation struct {
|
||||
AccountID int32
|
||||
RecomputeFrom time.Time
|
||||
}
|
||||
|
||||
// InvalidateSnapshot marque un compte comme nécessitant un recalcul depuis date.
|
||||
// Conserve le MIN si une invalidation antérieure existe déjà.
|
||||
func (s *Store) InvalidateSnapshot(ctx context.Context, accountID int32, from time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO account_snapshot_invalidation (account_id, recompute_from)
|
||||
VALUES ($1, $2::date)
|
||||
ON CONFLICT (account_id) DO UPDATE
|
||||
SET recompute_from = LEAST(account_snapshot_invalidation.recompute_from, EXCLUDED.recompute_from)
|
||||
`, accountID, from)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetInvalidations retourne tous les comptes en attente de recalcul.
|
||||
func (s *Store) GetInvalidations(ctx context.Context) ([]Invalidation, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT account_id, recompute_from FROM account_snapshot_invalidation ORDER BY account_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Invalidation
|
||||
for rows.Next() {
|
||||
var inv Invalidation
|
||||
if err := rows.Scan(&inv.AccountID, &inv.RecomputeFrom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetInvalidationsForOwner retourne les invalidations des comptes appartenant à ownerID.
|
||||
func (s *Store) GetInvalidationsForOwner(ctx context.Context, ownerID int32) ([]Invalidation, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT ai.account_id, ai.recompute_from
|
||||
FROM account_snapshot_invalidation ai
|
||||
JOIN account a ON a.id = ai.account_id
|
||||
WHERE a.owner_id = $1
|
||||
ORDER BY ai.account_id
|
||||
`, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Invalidation
|
||||
for rows.Next() {
|
||||
var inv Invalidation
|
||||
if err := rows.Scan(&inv.AccountID, &inv.RecomputeFrom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ClearInvalidation supprime le flag d'invalidation une fois le recalcul terminé.
|
||||
func (s *Store) ClearInvalidation(ctx context.Context, accountID int32) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM account_snapshot_invalidation WHERE account_id = $1`, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetInvalidation retourne l'invalidation d'un compte spécifique, si elle existe.
|
||||
func (s *Store) GetInvalidation(ctx context.Context, accountID int32) (Invalidation, bool, error) {
|
||||
var inv Invalidation
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT account_id, recompute_from FROM account_snapshot_invalidation WHERE account_id = $1`,
|
||||
accountID).Scan(&inv.AccountID, &inv.RecomputeFrom)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Invalidation{}, false, nil
|
||||
}
|
||||
return inv, err == nil, err
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package store
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
|
@ -105,9 +106,22 @@ func (s *Store) ListAccountTransactions(ctx context.Context, accountID int32, f
|
|||
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)
|
||||
// GetTransaction retourne une transaction. Si ownerID > 0, vérifie que l'un des
|
||||
// comptes impliqués appartient à cet owner (retourne ErrNoRows sinon).
|
||||
func (s *Store) GetTransaction(ctx context.Context, id int64, ownerID int32) (Transaction, error) {
|
||||
q := `SELECT t.id, t.date::text, t.account_source_id, t.instrument_source_id, t.quantite_source,
|
||||
t.account_dest_id, t.instrument_dest_id, t.quantite_dest,
|
||||
t.tiers, t.label, t.categorie, t.validated, t.recurring_rule_id
|
||||
FROM transaction t
|
||||
LEFT JOIN account src ON src.id = t.account_source_id
|
||||
LEFT JOIN account dst ON dst.id = t.account_dest_id
|
||||
WHERE t.id = $1`
|
||||
args := []any{id}
|
||||
if ownerID > 0 {
|
||||
q += ` AND (src.owner_id = $2 OR dst.owner_id = $2)`
|
||||
args = append(args, ownerID)
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return Transaction{}, err
|
||||
}
|
||||
|
|
@ -129,10 +143,21 @@ func (s *Store) CreateTransaction(ctx context.Context, p CreateTransactionParams
|
|||
if err != nil {
|
||||
return Transaction{}, err
|
||||
}
|
||||
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
||||
tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
||||
if err != nil {
|
||||
return tx, err
|
||||
}
|
||||
s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID)
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransactionParams) (Transaction, error) {
|
||||
func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransactionParams, ownerID int32) (Transaction, error) {
|
||||
// Récupérer l'ancienne date pour invalider à partir du MIN(ancienne, nouvelle).
|
||||
old, err := s.GetTransaction(ctx, id, ownerID)
|
||||
if err != nil {
|
||||
return Transaction{}, err
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`UPDATE transaction SET
|
||||
date = $2::date,
|
||||
|
|
@ -148,20 +173,68 @@ func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransac
|
|||
if err != nil {
|
||||
return Transaction{}, err
|
||||
}
|
||||
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
||||
tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
||||
if err != nil {
|
||||
return tx, err
|
||||
}
|
||||
// Invalider depuis la date la plus ancienne (ancienne ou nouvelle).
|
||||
earliest := minDateStr(old.Date, tx.Date)
|
||||
s.invalidateAccounts(ctx, earliest, tx.AccountSourceID, tx.AccountDestID)
|
||||
// Si les comptes ont changé, invalider aussi les anciens.
|
||||
s.invalidateAccounts(ctx, earliest, old.AccountSourceID, old.AccountDestID)
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Transaction, error) {
|
||||
func (s *Store) SetValidated(ctx context.Context, id int64, validated bool, ownerID int32) (Transaction, error) {
|
||||
// Vérifie l'ownership avant modification.
|
||||
if _, err := s.GetTransaction(ctx, id, ownerID); err != nil {
|
||||
return Transaction{}, err
|
||||
}
|
||||
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])
|
||||
tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction])
|
||||
if err != nil {
|
||||
return tx, err
|
||||
}
|
||||
s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID)
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTransaction(ctx context.Context, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM transaction WHERE id = $1`, id)
|
||||
return err
|
||||
func (s *Store) DeleteTransaction(ctx context.Context, id int64, ownerID int32) error {
|
||||
tx, err := s.GetTransaction(ctx, id, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `DELETE FROM transaction WHERE id = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateAccounts marque les comptes non-nil comme devant être recalculés depuis dateStr.
|
||||
func (s *Store) invalidateAccounts(ctx context.Context, dateStr string, accountIDs ...*int32) {
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
seen := map[int32]bool{}
|
||||
for _, id := range accountIDs {
|
||||
if id != nil && !seen[*id] {
|
||||
seen[*id] = true
|
||||
s.InvalidateSnapshot(ctx, *id, date) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// minDateStr retourne la plus petite des deux dates au format YYYY-MM-DD.
|
||||
func minDateStr(a, b string) string {
|
||||
if a <= b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ DROP TABLE IF EXISTS account_snapshot;
|
|||
DROP TABLE IF EXISTS position_snapshot;
|
||||
DROP TABLE IF EXISTS transaction;
|
||||
DROP TABLE IF EXISTS recurring_rule;
|
||||
DROP TABLE IF EXISTS envelope;
|
||||
DROP TABLE IF EXISTS account;
|
||||
DROP TABLE IF EXISTS instrument_ticker_cache;
|
||||
DROP TABLE IF EXISTS price_history;
|
||||
DROP TABLE IF EXISTS instrument;
|
||||
|
|
|
|||
|
|
@ -9,32 +9,34 @@ CREATE TABLE instrument (
|
|||
devise_cotation TEXT NOT NULL DEFAULT 'EUR'
|
||||
);
|
||||
|
||||
-- Historique de prix (hypertable TimescaleDB)
|
||||
-- Historique de prix intraday (hypertable TimescaleDB)
|
||||
-- Le job de nettoyage consolide à J-1 en gardant uniquement le dernier fetch
|
||||
CREATE TABLE price_history (
|
||||
instrument_id INTEGER NOT NULL REFERENCES instrument(id),
|
||||
date DATE NOT NULL,
|
||||
instrument_id INTEGER NOT NULL REFERENCES instrument(id),
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
prix NUMERIC(24, 8) NOT NULL,
|
||||
PRIMARY KEY (instrument_id, date)
|
||||
PRIMARY KEY (instrument_id, fetched_at)
|
||||
);
|
||||
SELECT create_hypertable('price_history', by_range('date'));
|
||||
SELECT create_hypertable('price_history', by_range('fetched_at'));
|
||||
CREATE INDEX idx_price_history_instrument ON price_history(instrument_id, fetched_at DESC);
|
||||
|
||||
-- Comptes : conteneurs de positions, aucun solde stocké
|
||||
-- Cache OpenFIGI (ISIN → ticker Yahoo Finance) et CoinGecko (ticker → coin ID)
|
||||
CREATE TABLE instrument_ticker_cache (
|
||||
instrument_id INTEGER PRIMARY KEY REFERENCES instrument(id) ON DELETE CASCADE,
|
||||
ticker TEXT NOT NULL,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Comptes et enveloppes : les enveloppes sont des sous-comptes (master_account_id non null)
|
||||
-- Un sous-compte hérite du type et de la devise de son maître ; pas de chaînage
|
||||
CREATE TABLE account (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nom TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- courant, livret, pea, cto, crypto, ...
|
||||
devise_reference TEXT NOT NULL DEFAULT 'EUR',
|
||||
plafond NUMERIC(24, 8),
|
||||
taux NUMERIC(10, 6)
|
||||
);
|
||||
|
||||
-- Enveloppes : ventilation logique EUR d'un compte (strictement monétaire)
|
||||
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
|
||||
id SERIAL PRIMARY KEY,
|
||||
nom TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- courant, livret, pea, cto, crypto, ...
|
||||
devise_reference TEXT NOT NULL DEFAULT 'EUR',
|
||||
plafond NUMERIC(24, 8), -- indicatif (ex : plafond réglementaire Livret A)
|
||||
master_account_id INTEGER REFERENCES account(id) ON DELETE CASCADE,
|
||||
objectif TEXT -- description libre pour les enveloppes
|
||||
);
|
||||
|
||||
-- Règles de récurrence
|
||||
|
|
@ -49,11 +51,10 @@ CREATE TABLE recurring_rule (
|
|||
tiers TEXT,
|
||||
label TEXT NOT NULL,
|
||||
categorie TEXT,
|
||||
envelope_id INTEGER REFERENCES envelope(id),
|
||||
frequence TEXT NOT NULL, -- RRULE string (RFC 5545)
|
||||
date_debut DATE NOT NULL,
|
||||
date_fin DATE,
|
||||
generated_until DATE -- curseur d'idempotence
|
||||
generated_until DATE -- curseur d'idempotence
|
||||
);
|
||||
|
||||
-- Transactions : échange atomique source/dest généralisé aux instruments
|
||||
|
|
@ -69,7 +70,6 @@ CREATE TABLE transaction (
|
|||
tiers TEXT,
|
||||
label TEXT NOT NULL,
|
||||
categorie TEXT,
|
||||
envelope_id INTEGER REFERENCES envelope(id),
|
||||
validated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recurring_rule_id INTEGER REFERENCES recurring_rule(id),
|
||||
|
||||
|
|
@ -100,12 +100,12 @@ CREATE TABLE transaction (
|
|||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transaction_date ON transaction(date);
|
||||
CREATE INDEX idx_transaction_account_source ON transaction(account_source_id) WHERE account_source_id IS NOT NULL;
|
||||
CREATE INDEX idx_transaction_account_dest ON transaction(account_dest_id) WHERE account_dest_id IS NOT NULL;
|
||||
CREATE INDEX idx_transaction_unvalidated ON transaction(date) WHERE validated = FALSE;
|
||||
CREATE INDEX idx_transaction_date ON transaction(date);
|
||||
CREATE INDEX idx_transaction_account_source ON transaction(account_source_id) WHERE account_source_id IS NOT NULL;
|
||||
CREATE INDEX idx_transaction_account_dest ON transaction(account_dest_id) WHERE account_dest_id IS NOT NULL;
|
||||
CREATE INDEX idx_transaction_unvalidated ON transaction(date) WHERE validated = FALSE;
|
||||
|
||||
-- Snapshots de position (hypertable TimescaleDB)
|
||||
-- Snapshots de position par instrument et par compte (hypertable TimescaleDB)
|
||||
CREATE TABLE position_snapshot (
|
||||
date DATE NOT NULL,
|
||||
account_id INTEGER NOT NULL REFERENCES account(id),
|
||||
|
|
@ -119,7 +119,7 @@ CREATE TABLE position_snapshot (
|
|||
SELECT create_hypertable('position_snapshot', by_range('date'));
|
||||
CREATE INDEX idx_position_snapshot_lookup ON position_snapshot(account_id, instrument_id, date DESC);
|
||||
|
||||
-- Snapshots de compte agrégés (hypertable TimescaleDB)
|
||||
-- Snapshots de valeur agrégée par compte (hypertable TimescaleDB)
|
||||
CREATE TABLE account_snapshot (
|
||||
date DATE NOT NULL,
|
||||
account_id INTEGER NOT NULL REFERENCES account(id),
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
DROP TABLE IF EXISTS instrument_ticker_cache;
|
||||
DROP TABLE IF EXISTS price_history;
|
||||
|
||||
CREATE TABLE price_history (
|
||||
instrument_id INTEGER NOT NULL REFERENCES instrument(id),
|
||||
date DATE NOT NULL,
|
||||
prix NUMERIC(24, 8) NOT NULL,
|
||||
PRIMARY KEY (instrument_id, date)
|
||||
);
|
||||
SELECT create_hypertable('price_history', by_range('date'));
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
-- Recréation de price_history avec support intraday (plusieurs prix par jour)
|
||||
-- Le job de nettoyage consolide à J-1 en gardant uniquement le dernier fetch
|
||||
DROP TABLE IF EXISTS price_history;
|
||||
|
||||
CREATE TABLE price_history (
|
||||
instrument_id INTEGER NOT NULL REFERENCES instrument(id),
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
prix NUMERIC(24, 8) NOT NULL,
|
||||
PRIMARY KEY (instrument_id, fetched_at)
|
||||
);
|
||||
SELECT create_hypertable('price_history', by_range('fetched_at'));
|
||||
CREATE INDEX idx_price_history_instrument ON price_history(instrument_id, fetched_at DESC);
|
||||
|
||||
-- Cache OpenFIGI (ISIN → ticker Yahoo Finance) et CoinGecko (ticker → coin ID)
|
||||
CREATE TABLE instrument_ticker_cache (
|
||||
instrument_id INTEGER PRIMARY KEY REFERENCES instrument(id) ON DELETE CASCADE,
|
||||
ticker TEXT NOT NULL,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
1
migrations/000002_snapshot_invalidation.down.sql
Normal file
1
migrations/000002_snapshot_invalidation.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS account_snapshot_invalidation;
|
||||
7
migrations/000002_snapshot_invalidation.up.sql
Normal file
7
migrations/000002_snapshot_invalidation.up.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- Marque les comptes dont les snapshots doivent être recalculés.
|
||||
-- recompute_from = date la plus ancienne à partir de laquelle recalculer.
|
||||
-- Mise à jour par MIN() à chaque mutation de transaction (create/update/delete/validate).
|
||||
CREATE TABLE account_snapshot_invalidation (
|
||||
account_id INTEGER PRIMARY KEY REFERENCES account(id) ON DELETE CASCADE,
|
||||
recompute_from DATE NOT NULL
|
||||
);
|
||||
1
migrations/000003_add_owner.down.sql
Normal file
1
migrations/000003_add_owner.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE account DROP COLUMN owner_id;
|
||||
1
migrations/000003_add_owner.up.sql
Normal file
1
migrations/000003_add_owner.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE account ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 1;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE account ADD COLUMN taux NUMERIC(10, 6);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
-- 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)
|
||||
);
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
-- 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)
|
||||
);
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE account ADD COLUMN montant_alloue NUMERIC(24, 8);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue