From 3785ac599a4d928e0413509cb52c4926a3ffbef6 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 14:19:13 +0200 Subject: [PATCH 1/7] move doc info Signed-off-by: GnomeZworc --- docs/{ => release}/.template.md | 0 docs/{ => release}/v0.1.0_cachalot.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename docs/{ => release}/.template.md (100%) rename docs/{ => release}/v0.1.0_cachalot.md (100%) diff --git a/docs/.template.md b/docs/release/.template.md similarity index 100% rename from docs/.template.md rename to docs/release/.template.md diff --git a/docs/v0.1.0_cachalot.md b/docs/release/v0.1.0_cachalot.md similarity index 100% rename from docs/v0.1.0_cachalot.md rename to docs/release/v0.1.0_cachalot.md From 33682eafb6cc4d7a349789e91426037c7ccdf71b Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 14:45:00 +0200 Subject: [PATCH 2/7] clean db Signed-off-by: GnomeZworc --- migrations/000001_init_schema.down.sql | 2 +- migrations/000001_init_schema.up.sql | 60 +++++++++---------- .../000002_price_history_intraday.down.sql | 10 ---- .../000002_price_history_intraday.up.sql | 19 ------ migrations/000003_drop_account_taux.down.sql | 1 - migrations/000003_drop_account_taux.up.sql | 3 - ...000004_envelopes_and_transactions.down.sql | 18 ------ .../000004_envelopes_and_transactions.up.sql | 49 --------------- .../000005_envelope_as_account.down.sql | 59 ------------------ migrations/000005_envelope_as_account.up.sql | 50 ---------------- .../000006_drop_montant_alloue.down.sql | 1 - migrations/000006_drop_montant_alloue.up.sql | 3 - 12 files changed, 31 insertions(+), 244 deletions(-) delete mode 100644 migrations/000002_price_history_intraday.down.sql delete mode 100644 migrations/000002_price_history_intraday.up.sql delete mode 100644 migrations/000003_drop_account_taux.down.sql delete mode 100644 migrations/000003_drop_account_taux.up.sql delete mode 100644 migrations/000004_envelopes_and_transactions.down.sql delete mode 100644 migrations/000004_envelopes_and_transactions.up.sql delete mode 100644 migrations/000005_envelope_as_account.down.sql delete mode 100644 migrations/000005_envelope_as_account.up.sql delete mode 100644 migrations/000006_drop_montant_alloue.down.sql delete mode 100644 migrations/000006_drop_montant_alloue.up.sql diff --git a/migrations/000001_init_schema.down.sql b/migrations/000001_init_schema.down.sql index 0f68c15..b107d46 100644 --- a/migrations/000001_init_schema.down.sql +++ b/migrations/000001_init_schema.down.sql @@ -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; diff --git a/migrations/000001_init_schema.up.sql b/migrations/000001_init_schema.up.sql index a81a880..90df706 100644 --- a/migrations/000001_init_schema.up.sql +++ b/migrations/000001_init_schema.up.sql @@ -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), diff --git a/migrations/000002_price_history_intraday.down.sql b/migrations/000002_price_history_intraday.down.sql deleted file mode 100644 index 42b971f..0000000 --- a/migrations/000002_price_history_intraday.down.sql +++ /dev/null @@ -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')); diff --git a/migrations/000002_price_history_intraday.up.sql b/migrations/000002_price_history_intraday.up.sql deleted file mode 100644 index 49bc9f0..0000000 --- a/migrations/000002_price_history_intraday.up.sql +++ /dev/null @@ -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() -); diff --git a/migrations/000003_drop_account_taux.down.sql b/migrations/000003_drop_account_taux.down.sql deleted file mode 100644 index 2fafcab..0000000 --- a/migrations/000003_drop_account_taux.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE account ADD COLUMN taux NUMERIC(10, 6); diff --git a/migrations/000003_drop_account_taux.up.sql b/migrations/000003_drop_account_taux.up.sql deleted file mode 100644 index f322a8b..0000000 --- a/migrations/000003_drop_account_taux.up.sql +++ /dev/null @@ -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; diff --git a/migrations/000004_envelopes_and_transactions.down.sql b/migrations/000004_envelopes_and_transactions.down.sql deleted file mode 100644 index 0aac6c4..0000000 --- a/migrations/000004_envelopes_and_transactions.down.sql +++ /dev/null @@ -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; diff --git a/migrations/000004_envelopes_and_transactions.up.sql b/migrations/000004_envelopes_and_transactions.up.sql deleted file mode 100644 index 8c35fbb..0000000 --- a/migrations/000004_envelopes_and_transactions.up.sql +++ /dev/null @@ -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) -); diff --git a/migrations/000005_envelope_as_account.down.sql b/migrations/000005_envelope_as_account.down.sql deleted file mode 100644 index 85faddb..0000000 --- a/migrations/000005_envelope_as_account.down.sql +++ /dev/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) -); diff --git a/migrations/000005_envelope_as_account.up.sql b/migrations/000005_envelope_as_account.up.sql deleted file mode 100644 index 3b86a96..0000000 --- a/migrations/000005_envelope_as_account.up.sql +++ /dev/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; diff --git a/migrations/000006_drop_montant_alloue.down.sql b/migrations/000006_drop_montant_alloue.down.sql deleted file mode 100644 index dc51e8f..0000000 --- a/migrations/000006_drop_montant_alloue.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE account ADD COLUMN montant_alloue NUMERIC(24, 8); diff --git a/migrations/000006_drop_montant_alloue.up.sql b/migrations/000006_drop_montant_alloue.up.sql deleted file mode 100644 index e3b6931..0000000 --- a/migrations/000006_drop_montant_alloue.up.sql +++ /dev/null @@ -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; From c73021a14fe25eb1fe5a53017d7d0b2eaee3afa8 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 14:45:25 +0200 Subject: [PATCH 3/7] mise en place test Signed-off-by: GnomeZworc --- Makefile | 2 +- cmd/api/integration_test.go | 509 ++++++++++++++++++++++++++++++++++++ 2 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 cmd/api/integration_test.go diff --git a/Makefile b/Makefile index 4e65695..ac753aa 100644 --- a/Makefile +++ b/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: diff --git a/cmd/api/integration_test.go b/cmd/api/integration_test.go new file mode 100644 index 0000000..b89d427 --- /dev/null +++ b/cmd/api/integration_test.go @@ -0,0 +1,509 @@ +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) + } + 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() + }) + + // Utiliser les variables pour éviter "declared but not used" + _ = eurID +} From 0f2d7126ebfceb883d4bfcbf0ae16a082306374b Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 22:52:13 +0200 Subject: [PATCH 4/7] ajoute snapshot Signed-off-by: GnomeZworc --- cmd/api/integration_test.go | 56 ++++ cmd/api/main.go | 7 + docs/api.md | 90 ++++++ internal/config/config.go | 36 ++- internal/handler/snapshot.go | 128 +++++++++ internal/server/server.go | 7 + internal/snapshot/engine.go | 212 ++++++++++++++ internal/store/snapshot.go | 271 ++++++++++++++++++ internal/store/transaction.go | 66 ++++- .../000002_snapshot_invalidation.down.sql | 1 + .../000002_snapshot_invalidation.up.sql | 7 + 11 files changed, 862 insertions(+), 19 deletions(-) create mode 100644 internal/handler/snapshot.go create mode 100644 internal/snapshot/engine.go create mode 100644 internal/store/snapshot.go create mode 100644 migrations/000002_snapshot_invalidation.down.sql create mode 100644 migrations/000002_snapshot_invalidation.up.sql diff --git a/cmd/api/integration_test.go b/cmd/api/integration_test.go index b89d427..16a632c 100644 --- a/cmd/api/integration_test.go +++ b/cmd/api/integration_test.go @@ -504,6 +504,62 @@ func TestAPI(t *testing.T) { 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 } diff --git a/cmd/api/main.go b/cmd/api/main.go index 445afc2..bcf971c 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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" ) @@ -40,6 +41,7 @@ func main() { logger.Info("database connected") st := store.New(pool) + snap := snapshot.New(st, logger, cfg.SnapshotHorizonDays) pl := pipeline.New(st, pipeline.Config{ OpenFIGIKey: cfg.OpenFIGIKey, CoinGeckoKey: cfg.CoinGeckoKey, @@ -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) diff --git a/docs/api.md b/docs/api.md index 4f5e3a9..724d8ee 100644 --- a/docs/api.md +++ b/docs/api.md @@ -381,3 +381,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 diff --git a/internal/config/config.go b/internal/config/config.go index d7d9019..b187f27 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, } } diff --git a/internal/handler/snapshot.go b/internal/handler/snapshot.go new file mode 100644 index 0000000..3332e2e --- /dev/null +++ b/internal/handler/snapshot.go @@ -0,0 +1,128 @@ +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" +) + +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) +} + +// 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 /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, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid account id") + 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, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid account id") + 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, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid account id") + 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", + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 99274f0..d6d5ac6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,6 +7,7 @@ import ( "git.g3e.fr/H6N/account/internal/config" "git.g3e.fr/H6N/account/internal/handler" + "git.g3e.fr/H6N/account/internal/snapshot" "git.g3e.fr/H6N/account/internal/store" "github.com/jackc/pgx/v5/pgxpool" ) @@ -35,10 +36,16 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) routes() { st := store.New(s.pool) + horizonDays := 30 + if s.cfg != nil { + horizonDays = s.cfg.SnapshotHorizonDays + } + eng := snapshot.New(st, s.logger, horizonDays) 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) } diff --git a/internal/snapshot/engine.go b/internal/snapshot/engine.go new file mode 100644 index 0000000..34866d8 --- /dev/null +++ b/internal/snapshot/engine.go @@ -0,0 +1,212 @@ +package snapshot + +import ( + "context" + "fmt" + "log/slog" + "time" + + "git.g3e.fr/H6N/account/internal/store" +) + +type Engine struct { + store *store.Store + logger *slog.Logger + horizonDays int +} + +func New(st *store.Store, logger *slog.Logger, horizonDays int) *Engine { + return &Engine{store: st, logger: logger, horizonDays: horizonDays} +} + +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")) + } + + for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { + if err := e.recomputeAccount(ctx, accountID, d); 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 { + baseDate, hasBase, err := e.store.GetLatestSnapshotDate(ctx, accountID, date) + if err != nil { + return fmt.Errorf("get latest snapshot date: %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 { + if pos.InstrumentType == "devise" { + prix = 1.0 + } else { + e.logger.Warn("snapshot: no price, position skipped", + "account_id", accountID, + "instrument_id", pos.InstrumentID, + "date", date.Format("2006-01-02"), + ) + continue + } + } + + valeur := pos.Quantite * prix + totalValeur += valeur + + snap := store.PositionSnapshot{ + Date: date, + AccountID: accountID, + InstrumentID: pos.InstrumentID, + Quantite: pos.Quantite, + PrixCloture: &prix, + Valeur: &valeur, + } + 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, + }) +} diff --git a/internal/store/snapshot.go b/internal/store/snapshot.go new file mode 100644 index 0000000..25d6d23 --- /dev/null +++ b/internal/store/snapshot.go @@ -0,0 +1,271 @@ +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 +} + +// 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() +} + +// 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 +} diff --git a/internal/store/transaction.go b/internal/store/transaction.go index 83f5836..f5da808 100644 --- a/internal/store/transaction.go +++ b/internal/store/transaction.go @@ -3,6 +3,7 @@ package store import ( "context" "fmt" + "time" "github.com/jackc/pgx/v5" ) @@ -129,10 +130,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) { + // Récupérer l'ancienne date pour invalider à partir du MIN(ancienne, nouvelle). + old, err := s.GetTransaction(ctx, id) + if err != nil { + return Transaction{}, err + } + rows, err := s.pool.Query(ctx, `UPDATE transaction SET date = $2::date, @@ -148,7 +160,16 @@ 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) { @@ -158,10 +179,45 @@ func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Tra 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 + tx, err := s.GetTransaction(ctx, id) + 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 } diff --git a/migrations/000002_snapshot_invalidation.down.sql b/migrations/000002_snapshot_invalidation.down.sql new file mode 100644 index 0000000..eeae05c --- /dev/null +++ b/migrations/000002_snapshot_invalidation.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS account_snapshot_invalidation; diff --git a/migrations/000002_snapshot_invalidation.up.sql b/migrations/000002_snapshot_invalidation.up.sql new file mode 100644 index 0000000..7e08b4a --- /dev/null +++ b/migrations/000002_snapshot_invalidation.up.sql @@ -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 +); From f862abe5a4f729801e239028e1584d58baafdad4 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 14 Jun 2026 13:40:51 +0200 Subject: [PATCH 5/7] add owner handle Signed-off-by: GnomeZworc --- cmd/api/integration_test.go | 1 + internal/auth/auth.go | 41 ++++++++++++++++ internal/handler/account.go | 64 ++++++++++++++++++++++--- internal/handler/response.go | 12 +++++ internal/handler/snapshot.go | 35 ++++++++++---- internal/handler/transaction.go | 61 ++++++++++++++++++++++-- internal/server/server.go | 8 +++- internal/snapshot/engine.go | 2 +- internal/store/account.go | 70 ++++++++++++++++++---------- internal/store/transaction.go | 33 +++++++++---- migrations/000003_add_owner.down.sql | 1 + migrations/000003_add_owner.up.sql | 1 + 12 files changed, 274 insertions(+), 55 deletions(-) create mode 100644 internal/auth/auth.go create mode 100644 migrations/000003_add_owner.down.sql create mode 100644 migrations/000003_add_owner.up.sql diff --git a/cmd/api/integration_test.go b/cmd/api/integration_test.go index 16a632c..ac42355 100644 --- a/cmd/api/integration_test.go +++ b/cmd/api/integration_test.go @@ -38,6 +38,7 @@ func (s *ts) do(method, path string, body any) *http.Response { 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") } diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..9e4729f --- /dev/null +++ b/internal/auth/auth.go @@ -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)))) + }) +} diff --git a/internal/handler/account.go b/internal/handler/account.go index 20eb0c5..873c96c 100644 --- a/internal/handler/account.go +++ b/internal/handler/account.go @@ -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 } diff --git a/internal/handler/response.go b/internal/handler/response.go index 43a60fc..e17ae33 100644 --- a/internal/handler/response.go +++ b/internal/handler/response.go @@ -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 +} diff --git a/internal/handler/snapshot.go b/internal/handler/snapshot.go index 3332e2e..7d51ab2 100644 --- a/internal/handler/snapshot.go +++ b/internal/handler/snapshot.go @@ -10,6 +10,26 @@ import ( "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 } @@ -37,9 +57,8 @@ func parseDateParam(r *http.Request, key string) (time.Time, error) { // 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, err := parseID(r) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid account id") + _, accountID, ok := h.verifyAccountOwner(w, r) + if !ok { return } from, err := parseDateParam(r, "from") @@ -67,9 +86,8 @@ func (h *SnapshotHandler) listAccountSnapshots(w http.ResponseWriter, r *http.Re // 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, err := parseID(r) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid account id") + _, accountID, ok := h.verifyAccountOwner(w, r) + if !ok { return } from, err := parseDateParam(r, "from") @@ -98,9 +116,8 @@ func (h *SnapshotHandler) listPositionSnapshots(w http.ResponseWriter, r *http.R // 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, err := parseID(r) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid account id") + _, accountID, ok := h.verifyAccountOwner(w, r) + if !ok { return } diff --git a/internal/handler/transaction.go b/internal/handler/transaction.go index 75c115a..419bff3 100644 --- a/internal/handler/transaction.go +++ b/internal/handler/transaction.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index d6d5ac6..15ddec6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,6 +5,7 @@ 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/snapshot" @@ -31,7 +32,12 @@ 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() { diff --git a/internal/snapshot/engine.go b/internal/snapshot/engine.go index 34866d8..1059df0 100644 --- a/internal/snapshot/engine.go +++ b/internal/snapshot/engine.go @@ -167,7 +167,7 @@ func (e *Engine) recomputeAccount(ctx context.Context, accountID int32, date tim var totalValeur float64 for _, pos := range positions { - if pos.Quantite <= 0 { + if pos.Quantite == 0 { continue } diff --git a/internal/store/account.go b/internal/store/account.go index a00aaed..17108fd 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -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 } diff --git a/internal/store/transaction.go b/internal/store/transaction.go index f5da808..14ecd60 100644 --- a/internal/store/transaction.go +++ b/internal/store/transaction.go @@ -106,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 } @@ -138,9 +151,9 @@ func (s *Store) CreateTransaction(ctx context.Context, p CreateTransactionParams 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) + old, err := s.GetTransaction(ctx, id, ownerID) if err != nil { return Transaction{}, err } @@ -172,7 +185,11 @@ func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransac 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) @@ -187,8 +204,8 @@ func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Tra return tx, nil } -func (s *Store) DeleteTransaction(ctx context.Context, id int64) error { - tx, err := s.GetTransaction(ctx, 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 } diff --git a/migrations/000003_add_owner.down.sql b/migrations/000003_add_owner.down.sql new file mode 100644 index 0000000..a813528 --- /dev/null +++ b/migrations/000003_add_owner.down.sql @@ -0,0 +1 @@ +ALTER TABLE account DROP COLUMN owner_id; diff --git a/migrations/000003_add_owner.up.sql b/migrations/000003_add_owner.up.sql new file mode 100644 index 0000000..c324f59 --- /dev/null +++ b/migrations/000003_add_owner.up.sql @@ -0,0 +1 @@ +ALTER TABLE account ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 1; From 28e082f0fce8d280e2d1915fd5d709740642eb73 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 14 Jun 2026 13:41:42 +0200 Subject: [PATCH 6/7] add correct backfile Signed-off-by: GnomeZworc --- cmd/api/main.go | 2 +- internal/handler/snapshot.go | 28 +++++++++++ internal/pipeline/coingecko.go | 43 +++++++++++++++++ internal/pipeline/pipeline.go | 49 +++++++++++++++++++ internal/pipeline/yahoo.go | 87 +++++++++++++++++++++++++++------- internal/server/server.go | 9 +++- internal/snapshot/engine.go | 79 +++++++++++++++++++++++------- internal/store/snapshot.go | 55 +++++++++++++++++++++ 8 files changed, 317 insertions(+), 35 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index bcf971c..2a8ed58 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -41,11 +41,11 @@ func main() { logger.Info("database connected") st := store.New(pool) - snap := snapshot.New(st, logger, cfg.SnapshotHorizonDays) pl := pipeline.New(st, pipeline.Config{ OpenFIGIKey: cfg.OpenFIGIKey, CoinGeckoKey: cfg.CoinGeckoKey, }, logger) + snap := snapshot.New(st, logger, cfg.SnapshotHorizonDays).WithBackfiller(pl) sched := scheduler.New(logger) sched.Add(scheduler.Job{ diff --git a/internal/handler/snapshot.go b/internal/handler/snapshot.go index 7d51ab2..41a4a7f 100644 --- a/internal/handler/snapshot.go +++ b/internal/handler/snapshot.go @@ -42,6 +42,7 @@ 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. @@ -53,6 +54,33 @@ func parseDateParam(r *http.Request, key string) (time.Time, error) { 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é. diff --git a/internal/pipeline/coingecko.go b/internal/pipeline/coingecko.go index 20d9a00..2bd472d 100644 --- a/internal/pipeline/coingecko.go +++ b/internal/pipeline/coingecko.go @@ -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) { diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 04b5f35..3139c28 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -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 { diff --git a/internal/pipeline/yahoo.go b/internal/pipeline/yahoo.go index f08eb68..d750576 100644 --- a/internal/pipeline/yahoo.go +++ b/internal/pipeline/yahoo.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 15ddec6..805320a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,6 +8,7 @@ import ( "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" @@ -43,10 +44,16 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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, + } } - eng := snapshot.New(st, s.logger, horizonDays) + 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) diff --git a/internal/snapshot/engine.go b/internal/snapshot/engine.go index 1059df0..f72127c 100644 --- a/internal/snapshot/engine.go +++ b/internal/snapshot/engine.go @@ -9,16 +9,28 @@ import ( "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. @@ -126,8 +138,11 @@ func (e *Engine) backfillAccount(ctx context.Context, accountID int32, from, 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.recomputeAccount(ctx, accountID, d); err != nil { + if err := e.recomputeAccountWithBackfill(ctx, accountID, d, backfilledInstruments); err != nil { return fmt.Errorf("recompute %s: %w", d.Format("2006-01-02"), err) } } @@ -135,11 +150,20 @@ func (e *Engine) backfillAccount(ctx context.Context, accountID int32, from, to } 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) @@ -175,30 +199,53 @@ func (e *Engine) recomputeAccount(ctx context.Context, accountID int32, date tim if err != nil { return fmt.Errorf("get price instrument %d: %w", pos.InstrumentID, err) } - if !found { - if pos.InstrumentType == "devise" { - prix = 1.0 - } else { - e.logger.Warn("snapshot: no price, position skipped", - "account_id", accountID, - "instrument_id", pos.InstrumentID, - "date", date.Format("2006-01-02"), - ) - continue - } + if !found && pos.InstrumentType == "devise" { + prix = 1.0 + found = true } - valeur := pos.Quantite * prix - totalValeur += valeur + // 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, - PrixCloture: &prix, - Valeur: &valeur, } + 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) } diff --git a/internal/store/snapshot.go b/internal/store/snapshot.go index 25d6d23..c4ee352 100644 --- a/internal/store/snapshot.go +++ b/internal/store/snapshot.go @@ -171,6 +171,37 @@ func nullableDate(t time.Time) any { 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) { @@ -251,6 +282,30 @@ func (s *Store) GetInvalidations(ctx context.Context) ([]Invalidation, error) { 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, From 020f7c83bb1845a33be203aca316b2fd6728fb95 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 14 Jun 2026 13:44:11 +0200 Subject: [PATCH 7/7] update doc with auth Signed-off-by: GnomeZworc --- docs/api.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/api.md b/docs/api.md index 724d8ee..f763191 100644 --- a/docs/api.md +++ b/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: +``` + +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`