diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c568828 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=account +DB_USER=account +DB_PASSWORD=account +DATABASE_URL=postgres://account:account@localhost:5432/account?sslmode=disable + +PORT=8080 +ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8e9f1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +.env +*.local diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4e65695 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +-include .env +export + +BINARY = bin/api +DB_URL ?= postgres://account:account@localhost:5432/account?sslmode=disable +GOBIN = $(shell go env GOPATH)/bin +MIGRATE = $(GOBIN)/migrate +SQLC = $(GOBIN)/sqlc + +.PHONY: build run test docker-up docker-down migrate-up migrate-down migrate-create sqlc-gen + +build: + go build -o $(BINARY) ./cmd/api + +run: + go run ./cmd/api + +test: + go test ./... + +## Docker +docker-up: + docker compose up -d + +docker-down: + docker compose down + +docker-reset: + docker compose down -v + +## Migrations (nécessite: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest) +migrate-up: + $(MIGRATE) -path migrations -database "$(DB_URL)" up + +migrate-down: + $(MIGRATE) -path migrations -database "$(DB_URL)" down 1 + +migrate-create: + @test -n "$(name)" || (echo "usage: make migrate-create name=xxx" && exit 1) + $(MIGRATE) create -ext sql -dir migrations -seq $(name) + +## sqlc (nécessite: go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest) +sqlc-gen: + $(SQLC) generate diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..fe69697 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.g3e.fr/H6N/account/internal/config" + "git.g3e.fr/H6N/account/internal/server" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + cfg := config.Load() + + ctx := context.Background() + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + logger.Error("failed to create db pool", "error", err) + os.Exit(1) + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + logger.Error("database ping failed", "error", err) + os.Exit(1) + } + logger.Info("database connected") + + srv := server.New(cfg, pool, logger) + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%s", cfg.Port), + Handler: srv, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + logger.Info("server listening", "port", cfg.Port, "env", cfg.Env) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("server error", "error", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + httpServer.Shutdown(shutdownCtx) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6e0938f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + db: + image: timescale/timescaledb:latest-pg16 + environment: + POSTGRES_DB: ${DB_NAME:-account} + POSTGRES_USER: ${DB_USER:-account} + POSTGRES_PASSWORD: ${DB_PASSWORD:-account} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-account}"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..db5eef4 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.g3e.fr/H6N/account + +go 1.25.0 + +require github.com/jackc/pgx/v5 v5.10.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c0e505b --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..967d158 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,24 @@ +package config + +import "os" + +type Config struct { + DatabaseURL string + Port string + Env string +} + +func Load() *Config { + return &Config{ + DatabaseURL: getenv("DATABASE_URL", "postgres://account:account@localhost:5432/account?sslmode=disable"), + Port: getenv("PORT", "8080"), + Env: getenv("ENV", "development"), + } +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..e49b697 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,46 @@ +package server + +import ( + "encoding/json" + "log/slog" + "net/http" + + "git.g3e.fr/H6N/account/internal/config" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Server struct { + mux *http.ServeMux + pool *pgxpool.Pool + logger *slog.Logger + cfg *config.Config +} + +func New(cfg *config.Config, pool *pgxpool.Pool, logger *slog.Logger) *Server { + s := &Server{ + mux: http.NewServeMux(), + pool: pool, + logger: logger, + cfg: cfg, + } + s.routes() + return s +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} + +func (s *Server) routes() { + s.mux.HandleFunc("GET /health", s.handleHealth) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if err := s.pool.Ping(r.Context()); err != nil { + s.logger.Error("health check failed", "error", err) + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/migrations/000001_init_schema.down.sql b/migrations/000001_init_schema.down.sql new file mode 100644 index 0000000..0f68c15 --- /dev/null +++ b/migrations/000001_init_schema.down.sql @@ -0,0 +1,8 @@ +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 price_history; +DROP TABLE IF EXISTS instrument; diff --git a/migrations/000001_init_schema.up.sql b/migrations/000001_init_schema.up.sql new file mode 100644 index 0000000..a81a880 --- /dev/null +++ b/migrations/000001_init_schema.up.sql @@ -0,0 +1,133 @@ +CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; + +-- Instruments : tout actif coté, y compris les devises (EUR prix = 1) +CREATE TABLE instrument ( + id SERIAL PRIMARY KEY, + type TEXT NOT NULL, -- devise, action, etf, crypto + code TEXT NOT NULL UNIQUE, -- EUR, ISIN, ticker + name TEXT NOT NULL, + devise_cotation TEXT NOT NULL DEFAULT 'EUR' +); + +-- Historique de prix (hypertable TimescaleDB) +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')); + +-- Comptes : conteneurs de positions, aucun solde stocké +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 +); + +-- Règles de récurrence +CREATE TABLE recurring_rule ( + id SERIAL PRIMARY KEY, + account_source_id INTEGER REFERENCES account(id), + instrument_source_id INTEGER REFERENCES instrument(id), + quantite_source NUMERIC(30, 18), + account_dest_id INTEGER REFERENCES account(id), + instrument_dest_id INTEGER REFERENCES instrument(id), + quantite_dest NUMERIC(30, 18), + 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 +); + +-- Transactions : échange atomique source/dest généralisé aux instruments +CREATE TABLE transaction ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL, + account_source_id INTEGER REFERENCES account(id), + instrument_source_id INTEGER REFERENCES instrument(id), + quantite_source NUMERIC(30, 18), + account_dest_id INTEGER REFERENCES account(id), + instrument_dest_id INTEGER REFERENCES instrument(id), + quantite_dest NUMERIC(30, 18), + 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), + + -- Chaque côté est soit entièrement renseigné soit entièrement null + -- Au moins un côté doit exister + 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) + ) +); + +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) +CREATE TABLE position_snapshot ( + date DATE NOT NULL, + account_id INTEGER NOT NULL REFERENCES account(id), + instrument_id INTEGER NOT NULL REFERENCES instrument(id), + quantite NUMERIC(30, 18) NOT NULL, + pru NUMERIC(24, 8), + prix_cloture NUMERIC(24, 8), + valeur NUMERIC(24, 8), + PRIMARY KEY (date, account_id, instrument_id) +); +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) +CREATE TABLE account_snapshot ( + date DATE NOT NULL, + account_id INTEGER NOT NULL REFERENCES account(id), + valeur NUMERIC(24, 8) NOT NULL, + PRIMARY KEY (date, account_id) +); +SELECT create_hypertable('account_snapshot', by_range('date')); +CREATE INDEX idx_account_snapshot_lookup ON account_snapshot(account_id, date DESC); + +-- EUR : instrument de base, prix toujours = 1 +INSERT INTO instrument (type, code, name, devise_cotation) VALUES ('devise', 'EUR', 'Euro', 'EUR'); diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..cbab56f --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "sqlc/queries/" + schema: "migrations/" + gen: + go: + package: "db" + out: "internal/db" + emit_json_tags: true + emit_interface: true + emit_methods_with_db_argument: true