Merge branch 'feature/snapshot'

This commit is contained in:
GnomeZworc 2026-06-14 20:59:37 +02:00
commit e22e8d4e5a
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
36 changed files with 1983 additions and 324 deletions

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package config
import (
"os"
"strconv"
"time"
)
@ -13,6 +14,7 @@ type Config struct {
CoinGeckoKey string
PriceFetchInterval time.Duration
PriceCleanInterval time.Duration
SnapshotHorizonDays int // nombre de jours dans le futur couverts par les snapshots
}
func Load() *Config {
@ -21,6 +23,11 @@ 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"),
@ -29,6 +36,7 @@ func Load() *Config {
CoinGeckoKey: os.Getenv("COINGECKO_API_KEY"),
PriceFetchInterval: fetchInterval,
PriceCleanInterval: 24 * time.Hour,
SnapshotHorizonDays: horizonDays,
}
}

View file

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

View file

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

View 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",
})
}

View file

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

View file

@ -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) {

View file

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

View file

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

View file

@ -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) {
// /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
View 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,
})
}

View file

@ -15,6 +15,7 @@ type Account struct {
Plafond *float64 `json:"plafond,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
View 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
}

View file

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

View file

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

View file

@ -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,
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
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,7 +51,6 @@ 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,
@ -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),
@ -105,7 +105,7 @@ CREATE INDEX idx_transaction_account_source ON transaction(account_source_id)
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),

View file

@ -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'));

View file

@ -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()
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS account_snapshot_invalidation;

View 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
);

View file

@ -0,0 +1 @@
ALTER TABLE account DROP COLUMN owner_id;

View file

@ -0,0 +1 @@
ALTER TABLE account ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 1;

View file

@ -1 +0,0 @@
ALTER TABLE account ADD COLUMN taux NUMERIC(10, 6);

View file

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

View file

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

View file

@ -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)
);

View file

@ -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)
);

View file

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

View file

@ -1 +0,0 @@
ALTER TABLE account ADD COLUMN montant_alloue NUMERIC(24, 8);

View file

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