feat: budget-tracker — spec, constitution, data-model, API contracts, plan

This commit is contained in:
Nox (OpenClaw)
2026-03-16 06:59:06 +00:00
commit 6895609edc
26 changed files with 4838 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
# Modèle de Données — Budget Tracker
## Principes directeurs
- Tous les montants sont stockés en **centimes** (INTEGER) — principe II de la Constitution
- Les suppressions sont des **soft-deletes** via `deleted_at` — principe II (tracabilité)
- Les identifiants sont des **UUID v4** — évite l'exposition de séquences prédictibles
- Les timestamps sont en **UTC** sans timezone stockée
---
## Entités
### User
| Champ | Type | Contraintes |
|-------|------|-------------|
| id | UUID | PK, auto (gen_random_uuid()) |
| email | VARCHAR(255) | UNIQUE, NOT NULL |
| hashed_password | VARCHAR(255) | NOT NULL |
| full_name | VARCHAR(100) | NOT NULL |
| is_active | BOOLEAN | DEFAULT true, NOT NULL |
| created_at | TIMESTAMP | NOT NULL, DEFAULT now() |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT now() |
**Notes** :
- `email` indexé (recherche lors de l'authentification)
- `hashed_password` : bcrypt, jamais exposé en API
- `is_active` : permet de désactiver un compte sans le supprimer
---
### Category
| Champ | Type | Contraintes |
|-------|------|-------------|
| id | UUID | PK, auto |
| user_id | UUID | FK → User.id, NOT NULL |
| name | VARCHAR(50) | NOT NULL |
| type | ENUM(income, expense) | NOT NULL |
| color | VARCHAR(7) | NULL, format hex (#RRGGBB) |
| icon | VARCHAR(50) | NULL, nom d'icône (ex: "shopping-cart") |
| is_default | BOOLEAN | DEFAULT false, NOT NULL |
| deleted_at | TIMESTAMP | NULL = actif |
| created_at | TIMESTAMP | NOT NULL, DEFAULT now() |
**Notes** :
- `is_default = true` : catégories système créées au premier login (Alimentation, Transport, Logement, Loisirs, Santé, Revenus)
- Les catégories par défaut ont `user_id` de l'utilisateur propriétaire — pas de catégories globales partagées (simplicité)
- Index : `(user_id, deleted_at)` pour les requêtes de listing
- Contrainte : impossible de supprimer si transactions actives liées (contrôlé en service, pas en DB)
---
### Transaction
| Champ | Type | Contraintes |
|-------|------|-------------|
| id | UUID | PK, auto |
| user_id | UUID | FK → User.id, NOT NULL |
| category_id | UUID | FK → Category.id, NOT NULL |
| amount_cents | INTEGER | NOT NULL, CHECK (amount_cents > 0) |
| type | ENUM(income, expense) | NOT NULL |
| description | VARCHAR(255) | NULL autorisé |
| transaction_date | DATE | NOT NULL |
| deleted_at | TIMESTAMP | NULL = actif |
| created_at | TIMESTAMP | NOT NULL, DEFAULT now() |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT now() |
**Notes** :
- `amount_cents > 0` : le signe est porté par `type`, jamais par le montant
- `transaction_date` : date métier saisie par l'utilisateur (≠ `created_at` technique)
- Index : `(user_id, transaction_date, deleted_at)` pour les requêtes par période
- Index : `(user_id, category_id, deleted_at)` pour les agrégats par catégorie
- Le soft-delete (`deleted_at IS NOT NULL`) masque la transaction de l'UI sans la détruire
---
### Budget
| Champ | Type | Contraintes |
|-------|------|-------------|
| id | UUID | PK, auto |
| user_id | UUID | FK → User.id, NOT NULL |
| category_id | UUID | FK → Category.id, NOT NULL |
| month | CHAR(7) | NOT NULL, format YYYY-MM |
| limit_cents | INTEGER | NOT NULL, CHECK (limit_cents > 0) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT now() |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT now() |
| UNIQUE | | (user_id, category_id, month) |
**Notes** :
- `month` en CHAR(7) format ISO `YYYY-MM` : simple, triable lexicographiquement, pas de confusion timezone
- La contrainte UNIQUE garantit un seul budget par catégorie par mois par utilisateur
- Index implicite sur la contrainte UNIQUE
- Pas de soft-delete : un budget supprimé est réellement supprimé (pas de données financières dans l'entité elle-même)
---
### RefreshToken
| Champ | Type | Contraintes |
|-------|------|-------------|
| id | UUID | PK, auto |
| user_id | UUID | FK → User.id, NOT NULL |
| token_hash | VARCHAR(255) | UNIQUE, NOT NULL |
| expires_at | TIMESTAMP | NOT NULL |
| revoked_at | TIMESTAMP | NULL = actif |
| created_at | TIMESTAMP | NOT NULL, DEFAULT now() |
**Notes** :
- Seul le hash du token est stocké (SHA-256), jamais le token brut
- Permet la révocation explicite (logout) et l'invalidation par rotation
- Nettoyage périodique des tokens expirés recommandé (tâche cron ou au login)
---
## Relations
```
User 1 ──────────────────────────── N Category
User 1 ──────────────────────────── N Transaction
User 1 ──────────────────────────── N Budget
User 1 ──────────────────────────── N RefreshToken
Category 1 ──────────────────────── N Transaction
Category 1 ──────────────────────── N Budget
```
---
## Requêtes critiques et leurs index
### Solde courant d'un utilisateur
```sql
SELECT
SUM(CASE WHEN type = 'income' THEN amount_cents ELSE -amount_cents END)
FROM transactions
WHERE user_id = :uid AND deleted_at IS NULL;
```
→ Index : `(user_id, deleted_at)`
### Résumé mensuel (revenus/dépenses d'un mois)
```sql
SELECT type, SUM(amount_cents)
FROM transactions
WHERE user_id = :uid
AND transaction_date >= :month_start
AND transaction_date < :month_end
AND deleted_at IS NULL
GROUP BY type;
```
→ Index : `(user_id, transaction_date, deleted_at)`
### Consommation d'un budget (dépenses catégorie × mois)
```sql
SELECT SUM(t.amount_cents)
FROM transactions t
JOIN budgets b ON b.category_id = t.category_id
WHERE t.user_id = :uid
AND t.category_id = :cat_id
AND t.type = 'expense'
AND t.transaction_date >= :month_start
AND t.transaction_date < :month_end
AND t.deleted_at IS NULL;
```
→ Index : `(user_id, category_id, deleted_at)`
---
## Valeurs par défaut — Catégories système
| Nom | Type | Couleur | Icône |
|-----|------|---------|-------|
| Alimentation | expense | #22c55e | utensils |
| Transport | expense | #3b82f6 | car |
| Logement | expense | #f59e0b | home |
| Loisirs | expense | #a855f7 | gamepad-2 |
| Santé | expense | #ef4444 | heart-pulse |
| Revenus | income | #10b981 | trending-up |
Créées automatiquement lors du premier login utilisateur (dans la logique de service, pas en seed DB globale).