# LifeTrack — Module FINANCE : modèle de données & logique > **Statut** : spécification prête pour implémentation — les agents d'implémentation ne feront **aucune** recherche complémentaire. > **Stack imposée** : Python 3.12 / FastAPI / SQLAlchemy 2.0 / PostgreSQL 16 — React 18 + TS + Vite + Tailwind + Apache ECharts. > **Conventions** : identifiants de code en **anglais**, prose et chaînes UI en **français**. Timestamps stockés en UTC (`TIMESTAMPTZ`), dates bancaires stockées telles quelles (`DATE`, dates civiles, **jamais** converties de fuseau). Fuseau d'affichage : `Europe/Paris`. --- ## 1. Vue d'ensemble Le module FINANCE couvre : 1. **Import** de relevés bancaires (CSV banques françaises, OFX) et d'exports d'activité PayPal (CSV), via le framework générique de connecteurs/importeurs de LifeTrack (les importeurs fichiers du module FINANCE sont des implémentations du contrat commun `FileImporter`). 2. **Liste unifiée de transactions** multi-comptes, avec déduplication robuste. 3. **Catégorisation automatique** par moteur de règles rejouable. 4. **Budgets mensuels** par catégorie, avec suivi budget vs réalisé. 5. **Détection de virements internes** (exclus des statistiques de dépenses). 6. **Détection de dépenses récurrentes** (abonnements, prélèvements) avec prédiction de la prochaine échéance. 7. **Tableaux de bord** : agrégats mensuels par catégorie (avec rollup de l'arbre de catégories), cashflow, top commerçants, sankey revenus → catégories — toutes les réponses sont *chart-ready* pour ECharts. ### 1.1 Principes transverses (rappel des conventions projet) - **Multi-utilisateur prêt** : toutes les tables métier portent `user_id` (FK vers `users.id`, table commune du socle). Toutes les requêtes filtrent par `user_id` (extrait du JWT). Aucune donnée partagée entre utilisateurs. - **Clés primaires** : `UUID` générés côté base via `gen_random_uuid()` (extension `pgcrypto` déjà activée par le socle). - **Horodatage** : `created_at TIMESTAMPTZ NOT NULL DEFAULT now()`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT now()` (trigger ou `onupdate` SQLAlchemy) sur toutes les tables. - **Montants** : `NUMERIC(12,2)` **signé**. Convention : **négatif = débit/dépense, positif = crédit/revenu**. Côté Python : `decimal.Decimal` exclusivement (jamais `float`). Côté JSON : nombres à 2 décimales. - **Devise** : `CHAR(3)` ISO 4217, défaut `'EUR'`. **v1 : aucune conversion de change** — les statistiques agrègent uniquement les transactions en EUR ; les autres devises sont listées mais exclues des agrégats (champ `excluded_foreign_currency` dans les réponses stats si pertinent). - **Schéma SQL** : toutes les tables du module vivent dans le schéma PostgreSQL par défaut `public`, préfixées `fin_` pour éviter les collisions inter-modules (`fin_accounts`, `fin_transactions`, …). Les modèles SQLAlchemy vivent dans `api/app/modules/finance/models.py`. ### 1.2 Diagramme entités-relations ```mermaid erDiagram users ||--o{ fin_accounts : owns users ||--o{ fin_categories : owns users ||--o{ fin_rules : owns users ||--o{ fin_budgets : owns users ||--o{ fin_import_runs : owns users ||--o{ fin_source_profiles : owns fin_accounts ||--o{ fin_transactions : contains fin_categories ||--o{ fin_categories : parent fin_categories ||--o{ fin_transactions : categorizes fin_categories ||--o{ fin_budgets : budgeted fin_import_runs ||--o{ fin_transactions : imported_by fin_source_profiles ||--o{ fin_import_runs : used_by ``` --- ## 2. Modèle de données — DDL exact Tous les `CREATE TYPE` / `CREATE TABLE` ci-dessous sont la **référence normative**. Les migrations Alembic doivent produire exactement ces structures (ordre de création : types → `fin_source_profiles` → `fin_accounts` → `fin_categories` → `fin_import_runs` → `fin_transactions` → `fin_rules` → `fin_budgets`). ### 2.0 Types énumérés ```sql CREATE TYPE fin_account_kind AS ENUM ('checking', 'savings', 'paypal', 'cash', 'other'); CREATE TYPE fin_category_kind AS ENUM ('income', 'expense', 'transfer'); CREATE TYPE fin_source_kind AS ENUM ('csv', 'ofx', 'paypal_csv'); CREATE TYPE fin_import_status AS ENUM ('pending', 'running', 'completed', 'failed'); CREATE TYPE fin_category_source AS ENUM ('rule', 'user'); ``` > SQLAlchemy : mapper via `sqlalchemy.Enum(..., name="fin_account_kind", create_type=False)` et créer les types dans la migration. Côté Python, définir des `enum.StrEnum` équivalents dans `api/app/modules/finance/enums.py` (ex. `AccountKind.CHECKING = "checking"`). ### 2.1 `fin_accounts` — comptes ```sql CREATE TABLE fin_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, -- ex. "Compte courant BoursoBank" kind fin_account_kind NOT NULL DEFAULT 'checking', currency CHAR(3) NOT NULL DEFAULT 'EUR', institution VARCHAR(100), -- ex. "BoursoBank", "PayPal" iban_masked VARCHAR(34), -- ex. "FR76 **** **** **** 1234" ; jamais l'IBAN complet initial_balance NUMERIC(12,2) NOT NULL DEFAULT 0, -- solde au point zéro (avant la 1re transaction importée) is_archived BOOLEAN NOT NULL DEFAULT FALSE, -- masqué des filtres par défaut, données conservées created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_fin_accounts_user_name UNIQUE (user_id, name) ); CREATE INDEX ix_fin_accounts_user ON fin_accounts(user_id); ``` Notes : - **Ne jamais stocker l'IBAN complet.** Si un import OFX fournit un numéro de compte, le masquer avant stockage (`****` + 4 derniers caractères). - `initial_balance` permet de calculer un solde courant : `initial_balance + SUM(amount)`. Le solde n'est pas stocké, il est calculé. - La suppression d'un compte est **refusée** (HTTP 409) s'il contient des transactions ; proposer l'archivage à la place. (`ON DELETE CASCADE` existe en base par sécurité, mais l'API bloque en amont.) ### 2.2 `fin_categories` — catégories (arbre) ```sql CREATE TABLE fin_categories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, parent_id UUID REFERENCES fin_categories(id) ON DELETE CASCADE, -- NULL = catégorie racine name VARCHAR(80) NOT NULL, -- ex. "Alimentation", "Courses" icon VARCHAR(50), -- nom d'icône (set lucide-react), ex. "shopping-cart" color CHAR(7), -- hex "#RRGGBB", hérite du parent si NULL kind fin_category_kind NOT NULL DEFAULT 'expense', is_system BOOLEAN NOT NULL DEFAULT FALSE, -- catégories créées au seed, non supprimables sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_fin_categories_sibling UNIQUE (user_id, parent_id, name) ); CREATE INDEX ix_fin_categories_user ON fin_categories(user_id); CREATE INDEX ix_fin_categories_parent ON fin_categories(parent_id); ``` Règles métier (validées côté API, pas en base) : - **Profondeur maximale : 2 niveaux** (racine + enfants). Refuser la création d'un enfant sous une catégorie qui a elle-même un parent (HTTP 422). - Un enfant hérite du `kind` de son parent (forcé à la création/édition). - Il existe **une seule** catégorie de `kind = 'transfer'` par utilisateur : la catégorie système **« Virements internes »** (créée au seed, `is_system = TRUE`). Le moteur de virements l'utilise ; interdire création/suppression d'autres catégories `transfer`. - La suppression d'une catégorie remet à `NULL` le `category_id` des transactions concernées (fait explicitement par l'API : `UPDATE fin_transactions SET category_id = NULL, category_source = NULL WHERE category_id IN (...)` avant le `DELETE`), et supprime les budgets et met à `NULL` les actions de règles qui la référencent. #### Seed par défaut (créé par le wizard de premier lancement pour chaque utilisateur) Racines `expense` : Alimentation (enfants : Courses, Restaurants & bars, Livraison), Logement (Loyer/Crédit, Énergie, Eau, Internet & mobile, Assurance habitation, Entretien), Transport (Carburant, Péages & parking, Transports en commun, Entretien véhicule, Assurance auto), Santé (Pharmacie, Médecin, Mutuelle), Loisirs (Abonnements & streaming, Jeux vidéo, Sorties, Sport, Vacances), Shopping (Vêtements, High-tech, Maison), Vape & tabac, Banque & frais (Frais bancaires, Intérêts), Impôts & taxes, Enfants & famille, Animaux, Dons & cadeaux, Autres dépenses. Racines `income` : Salaire, Aides & prestations (CAF, etc.), Remboursements (Santé, Autres), Ventes (Leboncoin, Vinted…), Intérêts & placements, Autres revenus. Racine `transfer` (système) : **Virements internes**. Chaque racine du seed reçoit une couleur distincte de la palette du design system et un `icon` ; les enfants héritent (`color = NULL`). ### 2.3 `fin_source_profiles` — profils de source d'import ```sql CREATE TABLE fin_source_profiles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- NULL = preset intégré (built-in), visible par tous name VARCHAR(100) NOT NULL, -- ex. "BoursoBank CSV", "PayPal (rapport d'activité)" kind fin_source_kind NOT NULL, config JSONB NOT NULL DEFAULT '{}', -- voir §3 pour le schéma exact par kind is_builtin BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT ck_fin_source_profiles_builtin CHECK ((is_builtin AND user_id IS NULL) OR (NOT is_builtin AND user_id IS NOT NULL)) ); CREATE UNIQUE INDEX uq_fin_source_profiles_builtin_name ON fin_source_profiles(name) WHERE user_id IS NULL; CREATE UNIQUE INDEX uq_fin_source_profiles_user_name ON fin_source_profiles(user_id, name) WHERE user_id IS NOT NULL; ``` - Les presets intégrés (§3.4) sont insérés par une migration de données Alembic. Ils sont **en lecture seule** via l'API ; l'utilisateur peut les **cloner** pour les personnaliser (endpoint `POST /source-profiles/{id}/clone`). ### 2.4 `fin_import_runs` — exécutions d'import ```sql CREATE TABLE fin_import_runs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, source_profile_id UUID REFERENCES fin_source_profiles(id) ON DELETE SET NULL, account_id UUID NOT NULL REFERENCES fin_accounts(id) ON DELETE CASCADE, -- compte cible choisi à l'import filename VARCHAR(255) NOT NULL, file_sha256 CHAR(64) NOT NULL, -- hash du fichier brut : avertir si fichier déjà importé à l'identique status fin_import_status NOT NULL DEFAULT 'pending', started_at TIMESTAMPTZ NOT NULL DEFAULT now(), finished_at TIMESTAMPTZ, stats JSONB NOT NULL DEFAULT '{}', error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ix_fin_import_runs_user_started ON fin_import_runs(user_id, started_at DESC); ``` Forme exacte de `stats` (renseignée à la fin du run) : ```json { "rows_total": 143, "rows_imported": 120, "rows_skipped_duplicate": 21, "rows_skipped_filtered": 1, "rows_error": 1, "rules_applied": 97, "transfers_detected": 2, "date_min": "2026-06-01", "date_max": "2026-07-31", "errors": [ { "row": 57, "message": "Date invalide : '32/06/2026'" } ] } ``` - `rows_skipped_filtered` : lignes volontairement ignorées par le parseur (ex. lignes PayPal de type `Authorization`, voir §3.3). - `errors` est plafonné à 50 entrées (au-delà : `"errors_truncated": true`). - **Rollback d'un import** : `DELETE /imports/{id}` supprime les transactions liées (`import_run_id = id`) puis le run. Refusé (409) si une transaction du run a été modifiée manuellement (`category_source = 'user'` ou notes non nulles) sauf si `?force=true`. ### 2.5 `fin_transactions` — transactions ```sql CREATE TABLE fin_transactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, account_id UUID NOT NULL REFERENCES fin_accounts(id) ON DELETE CASCADE, booked_date DATE NOT NULL, -- date comptable (celle du relevé) value_date DATE, -- date de valeur si fournie amount NUMERIC(12,2) NOT NULL, -- signé : négatif = débit, positif = crédit currency CHAR(3) NOT NULL DEFAULT 'EUR', label_raw TEXT NOT NULL, -- libellé brut d'origine, jamais modifié label_clean TEXT NOT NULL, -- libellé lisible ; initialisé = normalisation légère de label_raw, -- modifiable par règle ou par l'utilisateur counterparty VARCHAR(150), -- commerçant/tiers si identifiable (règle ou saisie) category_id UUID REFERENCES fin_categories(id) ON DELETE SET NULL, category_source fin_category_source, -- 'rule' | 'user' | NULL (non catégorisé) applied_rule_id UUID, -- FK logique vers fin_rules.id (pas de contrainte : règle supprimable) notes TEXT, import_run_id UUID REFERENCES fin_import_runs(id) ON DELETE SET NULL, -- NULL = saisie manuelle external_id VARCHAR(255), -- FITID OFX ou Transaction ID PayPal dedup_hash CHAR(64) NOT NULL, -- sha256 hex, voir §4.3 transfer_group_id UUID, -- deux jambes d'un virement interne partagent ce UUID created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_fin_transactions_dedup UNIQUE (account_id, dedup_hash) ); CREATE UNIQUE INDEX uq_fin_transactions_external ON fin_transactions(account_id, external_id) WHERE external_id IS NOT NULL; CREATE INDEX ix_fin_transactions_user_date ON fin_transactions(user_id, booked_date DESC); CREATE INDEX ix_fin_transactions_account_date ON fin_transactions(account_id, booked_date DESC); CREATE INDEX ix_fin_transactions_category ON fin_transactions(category_id); CREATE INDEX ix_fin_transactions_transfer ON fin_transactions(transfer_group_id) WHERE transfer_group_id IS NOT NULL; CREATE INDEX ix_fin_transactions_label_trgm ON fin_transactions USING gin (label_clean gin_trgm_ops); -- extension pg_trgm ``` Notes : - Activer l'extension `pg_trgm` dans la migration (`CREATE EXTENSION IF NOT EXISTS pg_trgm;`) pour la recherche plein-texte approximative sur `label_clean` (`ILIKE '%...%'` performant). - `applied_rule_id` est informatif (debug/traçabilité) ; volontairement **sans** contrainte FK pour que la suppression d'une règle ne touche pas aux transactions. - Saisie manuelle : `import_run_id = NULL`, `dedup_hash` calculé quand même (mêmes règles §4.3) pour protéger d'un doublon avec un import futur, `external_id = NULL`. - Une transaction avec `transfer_group_id IS NOT NULL` a toujours `category_id` = catégorie système « Virements internes » et `category_source` conservé tel quel (`rule`/`user`/NULL selon origine du marquage). ### 2.6 `fin_rules` — règles de catégorisation ```sql CREATE TABLE fin_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, -- ex. "Courses Carrefour" priority INTEGER NOT NULL DEFAULT 100, -- croissant = évalué en premier (1 avant 100) enabled BOOLEAN NOT NULL DEFAULT TRUE, stop BOOLEAN NOT NULL DEFAULT TRUE, -- TRUE : arrêt après application ; FALSE : les règles suivantes continuent matchers JSONB NOT NULL, -- schéma §5.1 actions JSONB NOT NULL, -- schéma §5.2 hit_count INTEGER NOT NULL DEFAULT 0, -- compteur cumulé d'applications (informatif) last_applied_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ix_fin_rules_user_priority ON fin_rules(user_id, priority, created_at); ``` Ordre d'évaluation déterministe : `ORDER BY priority ASC, created_at ASC, id ASC`. ### 2.7 `fin_budgets` — budgets mensuels ```sql CREATE TABLE fin_budgets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, category_id UUID NOT NULL REFERENCES fin_categories(id) ON DELETE CASCADE, monthly_amount NUMERIC(12,2) NOT NULL CHECK (monthly_amount > 0), -- toujours positif (plafond de dépense) start_month DATE NOT NULL, -- toujours le 1er du mois, ex. '2026-01-01' ; CHECK ci-dessous end_month DATE, -- 1er du dernier mois inclus ; NULL = sans fin created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT ck_fin_budgets_first_of_month CHECK (date_trunc('month', start_month) = start_month AND (end_month IS NULL OR date_trunc('month', end_month) = end_month)), CONSTRAINT ck_fin_budgets_range CHECK (end_month IS NULL OR end_month >= start_month) ); CREATE INDEX ix_fin_budgets_user_cat ON fin_budgets(user_id, category_id); ``` Règles métier : - Un budget cible une catégorie `kind = 'expense'` uniquement (validation API). - **Non-chevauchement** : pour une même `category_id`, les périodes `[start_month, end_month]` ne doivent pas se chevaucher. Validé côté API à la création/édition (requête d'intersection ; HTTP 409 si conflit). Changer le montant d'un budget « à partir du mois M » = clore l'ancien (`end_month = M - 1 mois`) + créer le nouveau (`start_month = M`) — l'API `PUT` propose ce comportement via le paramètre `effective_from`. - Un budget sur une catégorie **racine** couvre le rollup de tout son sous-arbre ; un budget sur un **enfant** ne couvre que lui. Si les deux existent, ils sont affichés tous les deux (le budget racine inclut la consommation de l'enfant — documenté dans l'UI). --- ## 3. Profils de source : schémas `config` et presets intégrés ### 3.1 `kind = 'csv'` — CSV générique à mapping configurable Schéma JSON complet de `config` (toutes clés présentes ; valeurs par défaut indiquées) : ```json { "encoding": "cp1252", // "utf-8" | "utf-8-sig" | "cp1252" | "iso-8859-15" | "auto" (voir §4.1) "delimiter": ";", // ";" | "," | "\t" | "|" "quote_char": "\"", "decimal_separator": ",", // "," | "." "thousands_separator": " ", // "" | " " | "." | " " (espace insécable, fréquent dans les exports FR) "date_format": "%d/%m/%Y", // format strptime Python "has_header": true, "skip_rows_top": 0, // lignes de préambule à ignorer AVANT l'en-tête (relevés CA/LBP) "skip_rows_bottom": 0, // lignes de pied (totaux) à ignorer "columns": { // Chaque valeur est SOIT un nom de colonne d'en-tête (string), SOIT un index 0-based (int) si has_header=false. "booked_date": "dateOp", // OBLIGATOIRE "value_date": "dateVal", // optionnel -> null "label": "label", // OBLIGATOIRE "amount": "amount", // mode "signed" : obligatoire ; sinon null "debit": null, // mode "split" : colonne débit (valeurs positives ou déjà négatives) "credit": null, // mode "split" : colonne crédit "currency": null, // optionnel ; sinon devise du compte cible "external_id": null // optionnel (rare en CSV bancaire) }, "amount_mode": "signed", // "signed" (une colonne signée) | "split" (colonnes débit/crédit séparées) "invert_sign": false, // true si la banque exporte les débits en positif dans la colonne signée "label_join": [], // colonnes supplémentaires concaténées au label (ex. ["category","supplierFound"]) — " — " comme séparateur "skip_row_if": [] // filtres d'exclusion : [{"column": "type", "equals": "Solde"}] } ``` Règles de parsing : - Mode `split` : `amount = credit - abs(debit)` ; une ligne doit avoir exactement une des deux colonnes non vide, sinon `rows_error`. - Montants : retirer `thousands_separator` et les espaces, remplacer `decimal_separator` par `.`, retirer un éventuel symbole `€` ou `EUR`, parser en `Decimal`. Gérer le signe unicode `−` (U+2212) comme `-`. - Dates : parser avec `datetime.strptime(value.strip(), date_format).date()`. Échec → ligne en erreur (l'import continue). - Résolution des colonnes par nom : insensible à la casse et aux accents, espaces trimés. ### 3.2 `kind = 'ofx'` — OFX (Open Financial Exchange) `config` : ```json { "fallback_encoding": "cp1252", // utilisé si l'en-tête OFX ne déclare pas de charset exploitable "account_match": null // optionnel : si le fichier contient plusieurs comptes, ACCTID à retenir ; null = premier compte + avertissement } ``` Spécification de parsing (l'implémentation utilise la bibliothèque **`ofxtools`** ; si le fichier la fait échouer, fallback sur un parseur tolérant par expressions régulières décrit ci-dessous) : - **OFX 1.x (SGML)** : en-tête texte `OFXHEADER:100 ... ENCODING:USASCII / CHARSET:1252`. Les banques françaises livrent quasi systématiquement du **cp1252**. Décoder selon `CHARSET` si présent, sinon `fallback_encoding`. - **OFX 2.x (XML)** : prologue XML standard, encodage déclaré dans le prologue. - Champs extraits par transaction (bloc ``) : - `DTPOSTED` → `booked_date`. Format `YYYYMMDD` éventuellement suivi de `HHMMSS[.XXX][gmt offset]` — ne garder que les 8 premiers chiffres, **sans conversion de fuseau**. - `TRNAMT` → `amount` (point décimal, déjà signé — négatif = débit). - `FITID` → `external_id` (déduplication prioritaire, §4.3). - `NAME` + `MEMO` → `label_raw = NAME` si `MEMO` vide, sinon `NAME + " — " + MEMO` (si `NAME` absent : `MEMO` seul). - `TRNTYPE` : ignoré pour le montant (déjà signé), mais concaténé nulle part ; conservé uniquement si besoin futur. - `CURDEF` du relevé → `currency`. - Fallback regex (OFX 1.x mal formé, très courant) : découper sur ``, extraire chaque champ par `r"([^<\r\n]*)"`. Tolérer l'absence de balises fermantes (SGML). ### 3.3 `kind = 'paypal_csv'` — export d'activité PayPal PayPal (paypal.com → Activité → Télécharger → « Tous les types de transactions », CSV). Colonnes du rapport FR (l'ordre peut varier, résoudre **par nom d'en-tête**, insensible casse/accents) : `Date`, `Heure`, `Fuseau horaire`, `Nom`, `Type`, `État`, `Devise`, `Brut`, `Frais`, `Net`, `Adresse email de l'expéditeur`, `Adresse email du destinataire`, `Numéro de transaction`, `Titre de l'objet`, `Numéro de la transaction de référence`, `Solde`, ... Le rapport EN utilise : `Date`, `Time`, `TimeZone`, `Name`, `Type`, `Status`, `Currency`, `Gross`, `Fee`, `Net`, `From Email Address`, `To Email Address`, `Transaction ID`, `Item Title`, `Reference Txn ID`, `Balance`. Le parseur mappe les **deux** jeux d'en-têtes (dictionnaire d'alias intégré au code). `config` : ```json { "encoding": "utf-8-sig", // PayPal exporte en UTF-8 avec BOM "delimiter": ",", "decimal_separator": ",", // export FR : virgule ; export EN : point — "auto" essaie "," puis "." "date_format": "%d/%m/%Y", "use_net_amount": true, // montant = Net (Brut - Frais) ; false = Brut "skip_types": [ "Autorisation", "Authorization", "Commande", "Order", "Annulation d'autorisation", "Void of Authorization", "Retenue pour vérification par PayPal", "Payment Review Hold", "Annulation de la retenue", "Payment Review Release" ], "skip_status_not_completed": true, // ne garder que État = "Effectué"/"Completed" "conversion_as_skip": true // lignes "Conversion de devise générale"/"General Currency Conversion" ignorées (comptées en rows_skipped_filtered) } ``` Règles spécifiques : - `Numéro de transaction` / `Transaction ID` → `external_id` (**toujours présent et unique** chez PayPal : c'est la clé de dédup prioritaire). - `label_raw = Nom + " — " + Type` (+ `" — " + Titre de l'objet` si non vide). `counterparty` = `Nom` directement. - `amount` = colonne `Net` (signée par PayPal : paiement envoyé = négatif). `currency` = colonne `Devise`. - Les paires de conversion de devise (deux lignes, une par devise) sont ignorées si `conversion_as_skip = true` — sinon elles créeraient un faux revenu et une fausse dépense. Le montant réellement débité apparaît sur la ligne de paiement principale. - Le compte cible d'un import PayPal est typiquement un compte `kind = 'paypal'`. Le rechargement PayPal depuis la banque apparaîtra des deux côtés → détecté comme virement interne (§6). ### 3.4 Presets intégrés (migration de données) 8 lignes insérées avec `user_id = NULL, is_builtin = TRUE` : | `name` | `kind` | Particularités du `config` | |---|---|---| | `CSV générique` | `csv` | Le config de §3.1 tel quel (valeurs par défaut) ; l'UI d'import propose un « aperçu + mapping » basé sur ce preset. | | `BoursoBank / Boursorama (CSV)` | `csv` | `delimiter=";"`, `encoding="utf-8-sig"`, `date_format="%Y-%m-%d"`, colonnes : `booked_date="dateOp"`, `value_date="dateVal"`, `label="label"`, `amount="amount"`, `amount_mode="signed"`, `decimal_separator=","`, `label_join=["supplierFound"]`. | | `Crédit Agricole (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=9` (préambule de relevé), `amount_mode="split"`, colonnes : `booked_date="Date"`, `label="Libellé"`, `debit="Débit euros"`, `credit="Crédit euros"`, `decimal_separator=","`, `thousands_separator=" "`. | | `La Banque Postale (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=6`, `amount_mode="signed"`, colonnes : `booked_date="Date"`, `label="Libellé"`, `amount="Montant(EUROS)"`, `decimal_separator=","`. | | `Société Générale (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=2`, `amount_mode="signed"`, colonnes : `booked_date="Date de l'opération"` (alias `"Date"`), `label="Libellé"` (alias `"Détail de l'écriture"`), `amount="Montant de l'opération"` (alias `"Montant"`), `decimal_separator=","`. | | `Fortuneo (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `amount_mode="split"`, colonnes : `booked_date="Date opération"`, `value_date="Date valeur"`, `label="Libellé"`, `debit="Débit"`, `credit="Crédit"`, `decimal_separator=","`. | | `OFX (toutes banques)` | `ofx` | Config §3.2 par défaut. À privilégier quand la banque propose l'OFX (dédup via FITID). | | `PayPal — rapport d'activité (CSV)` | `paypal_csv` | Config §3.3 par défaut. | > **Important pour l'implémentation** : les layouts CSV des banques changent régulièrement. Ces presets sont des **valeurs de départ raisonnables** ; l'écran d'import doit TOUJOURS afficher un aperçu des 20 premières lignes parsées (dates, montants, libellés) avant confirmation, et permettre d'ajuster le mapping (ce qui clone le preset en profil utilisateur). Les alias de noms de colonnes indiqués ci-dessus sont mis dans le config sous forme de listes : toute valeur de `columns.*` peut être `string | int | string[]` (première colonne trouvée dans l'en-tête). --- ## 4. Pipeline d'import — logique détaillée Module : `api/app/modules/finance/importer/`. Point d'entrée : `run_import(user, account, profile, file_bytes, filename) -> ImportRun`. Exécution **synchrone** dans la requête HTTP (fichiers < 5 Mo, quelques milliers de lignes : < 2 s) ; statut `running` → `completed`/`failed`. Limite upload : 20 Mo (HTTP 413 au-delà). ### 4.1 Étape 1 — Décodage ```python def decode_bytes(raw: bytes, encoding_cfg: str) -> str: if raw.startswith(b"\xef\xbb\xbf"): return raw.decode("utf-8-sig") if encoding_cfg != "auto": return raw.decode(encoding_cfg, errors="replace") # mode "auto" : utf-8 strict d'abord, sinon cp1252 (jamais d'échec : cp1252 décode tout octet) try: return raw.decode("utf-8") except UnicodeDecodeError: return raw.decode("cp1252") ``` ### 4.2 Étapes 2–3 — Parsing + normalisation Chaque parseur (`CsvParser`, `OfxParser`, `PaypalCsvParser` — sélectionné par `profile.kind`) produit une liste de `NormalizedRow` : ```python @dataclass class NormalizedRow: booked_date: date value_date: date | None amount: Decimal # signé, quantifié à 2 décimales : amount.quantize(Decimal("0.01"), ROUND_HALF_UP) currency: str # ISO 4217 upper ; défaut = account.currency label_raw: str # brut, trimé, sauts de ligne remplacés par un espace counterparty: str | None # renseigné uniquement par le parseur PayPal external_id: str | None source_row_index: int # index 1-based dans le fichier (pour les messages d'erreur) ``` Les lignes invalides (date/montant imparsables) sont collectées dans `stats.errors` **sans interrompre** l'import. Si `rows_error == rows_total` (aucune ligne valide), le run passe en `failed` avec `error_message = "Aucune ligne exploitable — vérifiez le profil de source."`. ### 4.3 Étape 4 — Normalisation du libellé et `dedup_hash` Deux normalisations distinctes, dans `importer/normalize.py` : ```python def normalize_label_for_hash(label: str) -> str: """Normalisation STABLE et CONSERVATRICE : utilisée pour le hash de dédup. Ne retire aucune information variable, sinon deux transactions distinctes fusionneraient. Uppercase + accents retirés + espaces normalisés, rien d'autre.""" s = unicodedata.normalize("NFKD", label) s = "".join(c for c in s if not unicodedata.combining(c)) # é -> e s = s.upper() s = re.sub(r"\s+", " ", s).strip() return s def compute_dedup_hash(account_id: UUID, booked_date: date, amount: Decimal, label_raw: str, occurrence: int) -> str: canonical = "|".join([ str(account_id), booked_date.isoformat(), # "2026-08-13" f"{amount:.2f}", # "-12.50" (signe inclus) normalize_label_for_hash(label_raw), str(occurrence), # rang parmi les lignes identiques du MÊME fichier (0,1,2…) ]) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() ``` - **`occurrence`** résout le cas réel de deux transactions identiques le même jour (deux cafés à 2,50 €) : dans un même fichier, les lignes de tuple `(booked_date, amount, label_norm)` identique sont numérotées 0, 1, 2… dans l'ordre du fichier. Les exports bancaires étant cumulatifs et ordonnés, un ré-import chevauchant reproduit les mêmes rangs → dédup correcte, sans perdre de vraie transaction dupliquée. - Le hash inclut `account_id` par défense en profondeur, même si la contrainte est déjà scopée compte. - `label_clean` initial = normalisation **légère** de `label_raw` : trim + espaces collapsés + suppression des préfixes bancaires purement techniques via la regex `^(CARTE \d{2}/\d{2}(/\d{2,4})? |PAIEMENT (PSC |CB )?\d{4} |PRLV SEPA |VIR SEPA |VIR INST |ACHAT CB )` (insensible à la casse). Le reste est conservé tel quel — les règles et l'utilisateur affinent ensuite. ### 4.4 Étapes 5–7 — Dédup, règles, insertion (pseudocode complet) ```python def run_import(user, account, profile, file_bytes, filename) -> ImportRun: run = create_import_run(user, account, profile, filename, file_sha256=sha256(file_bytes), status="running") # Avertissement fichier identique (non bloquant) : # si un run 'completed' du même user a le même file_sha256 -> stats["duplicate_file_of"] = run_id text = decode_bytes(file_bytes, profile.config.get("encoding", "auto")) rows = PARSERS[profile.kind](profile.config, text, account).parse() # list[NormalizedRow] + erreurs # -- numérotation des occurrences intra-fichier -- counter: dict[tuple, int] = defaultdict(int) prepared = [] for row in rows: key = (row.booked_date, row.amount, normalize_label_for_hash(row.label_raw)) occ = counter[key]; counter[key] += 1 prepared.append((row, compute_dedup_hash(account.id, row.booked_date, row.amount, row.label_raw, occ))) # -- pré-chargement des doublons existants (1 requête, pas N) -- dmin, dmax = min(r.booked_date for r, _ in prepared), max(r.booked_date for r, _ in prepared) existing_hashes = set(select fin_transactions.dedup_hash where account_id = account.id and booked_date between dmin and dmax) existing_ext = set(select external_id ... where external_id is not null and account_id = account.id) # sans borne de date : FITID est global au compte rules = load_enabled_rules(user) # ORDER BY priority, created_at, id to_insert, skipped = [], 0 for row, dhash in prepared: if (row.external_id and row.external_id in existing_ext) or dhash in existing_hashes: skipped += 1 continue existing_hashes.add(dhash) # protège aussi des doublons intra-fichier après occurrence tx = build_transaction(user, account, row, dhash, run, label_clean=light_clean(row.label_raw)) apply_rules_to_tx(tx, rules) # §5.3 — mutation en mémoire avant insert to_insert.append(tx) bulk_insert(to_insert) # une seule transaction SQL pour tout le run transfers = detect_transfers(user, date_min=dmin - timedelta(days=3), date_max=dmax + timedelta(days=3)) # §6 finalize_run(run, stats={...}, status="completed") return run ``` Garanties : - **Idempotence** : ré-importer le même fichier (ou un export chevauchant) n'insère aucun doublon. - **Atomicité** : l'insertion des transactions du run est une transaction SQL unique ; en cas d'exception, rollback complet et `status = 'failed'`. - La détection de virements est relancée automatiquement sur la fenêtre de dates importée (± 3 jours). --- ## 5. Moteur de règles ### 5.1 Schéma JSON de `matchers` Toutes les conditions présentes sont **combinées en ET**. Clés absentes ou `null` = non contraint. Au moins une clé non nulle exigée (validation API). ```json { "label_contains": ["CARREFOUR", "CRF "], // OU logique entre les éléments ; comparaison sur // normalize_label_for_hash(label_raw) ET sur label_clean normalisé // (insensible casse/accents) ; match par sous-chaîne "label_regex": "^CB\\s+CARREFOUR\\b", // regex Python (module re), flags IGNORECASE appliqué, // testée sur label_raw puis label_clean ; regex invalide = 422 à la sauvegarde "amount_min": -200.00, // borne inférieure INCLUSIVE sur le montant SIGNÉ "amount_max": -0.01, // borne supérieure INCLUSIVE sur le montant SIGNÉ "direction": "debit", // "debit" (amount < 0) | "credit" (amount > 0) | "any" (défaut) "account_id": null // UUID : restreint la règle à un compte } ``` > Note UX : l'UI présente `amount_min`/`amount_max` en valeur absolue avec le sélecteur `direction` ; l'API stocke les bornes signées telles quelles. ### 5.2 Schéma JSON de `actions` Au moins une clé non nulle exigée. ```json { "set_category_id": "d290f1ee-...", // UUID d'une catégorie de l'utilisateur (validé à la sauvegarde) "set_label_clean": "Carrefour", // remplace label_clean "set_counterparty": "Carrefour", // remplace counterparty "mark_transfer": false // true : marque la transaction comme virement interne // (catégorie forcée = « Virements internes » ; transfer_group_id reste NULL // jusqu'à appariement par le détecteur §6 — jambe « orpheline » acceptée) } ``` ### 5.3 Application — pseudocode ```python def rule_matches(rule, tx) -> bool: m = rule.matchers if m.get("account_id") and str(tx.account_id) != m["account_id"]: return False if m.get("direction") == "debit" and tx.amount >= 0: return False if m.get("direction") == "credit" and tx.amount <= 0: return False if m.get("amount_min") is not None and tx.amount < Decimal(str(m["amount_min"])): return False if m.get("amount_max") is not None and tx.amount > Decimal(str(m["amount_max"])): return False if m.get("label_contains"): hay = normalize_label_for_hash(tx.label_raw) + " | " + normalize_label_for_hash(tx.label_clean) if not any(normalize_label_for_hash(n) in hay for n in m["label_contains"]): return False if m.get("label_regex"): rx = compiled_regex_cache[rule.id] # re.compile(pattern, re.IGNORECASE) mis en cache if not (rx.search(tx.label_raw) or rx.search(tx.label_clean)): return False return True def apply_rules_to_tx(tx, rules) -> None: """Ne touche JAMAIS une catégorisation manuelle (category_source == 'user'), sauf mode force explicite (§5.4).""" for rule in rules: # déjà triées par priorité if not rule_matches(rule, tx): continue a = rule.actions if a.get("set_category_id") and tx.category_source != "user": tx.category_id, tx.category_source, tx.applied_rule_id = a["set_category_id"], "rule", rule.id if a.get("set_label_clean"): tx.label_clean = a["set_label_clean"] if a.get("set_counterparty"): tx.counterparty = a["set_counterparty"] if a.get("mark_transfer") and tx.category_source != "user": tx.category_id, tx.category_source = transfer_category_id(tx.user_id), "rule" rule.hit_count += 1 if rule.stop: break ``` ### 5.4 Ré-exécution à la demande (`POST /rules/apply`) Paramètres : `scope` (`"uncategorized"` par défaut — uniquement `category_id IS NULL` ; `"all_non_manual"` — tout sauf `category_source = 'user'` ; `"all"` — tout, **écrase** même le manuel, protégé par `force: true` obligatoire), `date_from`/`date_to` optionnels, `account_id` optionnel, `rule_id` optionnel (tester une seule règle), `dry_run` (défaut `false`). Traitement par lots de 1 000 transactions (streaming SQLAlchemy `yield_per`), réponse : ```json { "scanned": 4210, "matched": 1830, "updated": 1790, "dry_run": false, "by_rule": [ { "rule_id": "…", "name": "Courses Carrefour", "matched": 240 } ] } ``` En `dry_run`, rien n'est écrit (rollback), les compteurs sont retournés à l'identique. --- ## 6. Détection de virements internes Service `detect_transfers(user, date_min=None, date_max=None) -> int` (nombre de paires créées). Lancé : automatiquement en fin d'import (fenêtre du fichier ± 3 jours), et à la demande (`POST /transfers/detect`). ```python def detect_transfers(user, date_min=None, date_max=None) -> int: # Candidats : transactions non encore appariées, comptes de l'utilisateur, même devise txs = load(user, transfer_group_id=None, date_between=(date_min, date_max)) debits = [t for t in txs if t.amount < 0] credits_by_amount = index_by(lambda t: -t.amount, [t for t in txs if t.amount > 0]) pairs = [] for d in debits: for c in credits_by_amount.get(-d.amount, []): if c.account_id == d.account_id: continue # même compte : pas un virement if c.currency != d.currency: continue delta = abs((c.booked_date - d.booked_date).days) if delta > 3: continue # tolérance ±3 jours score = (3 - delta) * 10 # proximité de date d'abord joined = normalize_label_for_hash(d.label_raw + " " + c.label_raw) if re.search(r"\bVIR(EMENT)?\b|\bVIRT\b|TRANSFERT|PAYPAL", joined): score += 5 pairs.append((score, d, c)) pairs.sort(key=lambda p: (-p[0], p[1].booked_date)) # gloutonne : meilleurs scores d'abord used, created = set(), 0 for score, d, c in pairs: if d.id in used or c.id in used: continue gid = uuid4() for t in (d, c): t.transfer_group_id = gid if t.category_source != "user": # ne pas écraser un choix manuel t.category_id, t.category_source = transfer_category_id(user.id), "rule" used |= {d.id, c.id}; created += 1 return created ``` Règles associées : - **Exclusion des statistiques** : toute requête d'agrégat de dépenses/revenus (§8) filtre `transfer_group_id IS NULL AND (category IS NULL OR category.kind <> 'transfer')`. Les virements restent visibles dans la liste des transactions (badge « Virement interne »). - **Liaison manuelle** : `POST /transfers/link {transaction_id_a, transaction_id_b}` — valide montants opposés (sinon 422 avec message explicite ; tolérance zéro sur le montant), comptes différents ; crée le groupe. - **Déliaison** : `DELETE /transfers/{transfer_group_id}` — remet `transfer_group_id = NULL` sur les deux jambes et `category_id = NULL, category_source = NULL` si la catégorie était « Virements internes » posée par `rule`. - Une jambe peut rester orpheline (ex. compte PayPal pas encore importé) : marquée transfer par règle (§5.2 `mark_transfer`), elle sera appariée au prochain `detect_transfers`. --- ## 7. Détection de récurrences Service **calculé à la volée** (pas de table dédiée en v1 ; si le temps de calcul dépasse ~200 ms sur des volumes réels, ajouter une table de cache `fin_recurring_series` recalculée après chaque import — hors périmètre v1). Module : `finance/recurring.py`. ### 7.1 Clé de regroupement (`merchant_key`) Normalisation **agressive**, distincte de celle du hash : ```python def merchant_key(label_raw: str, counterparty: str | None) -> str: if counterparty: return normalize_label_for_hash(counterparty) s = normalize_label_for_hash(label_raw) s = re.sub(r"\b\d{2}/\d{2}(/\d{2,4})?\b", "", s) # dates dans le libellé s = re.sub(r"\b\d{4,}\b", "", s) # numéros (carte, référence, facture) s = re.sub(r"\b(CB|CARTE|PRLV|SEPA|VIR|ECH|PAIEMENT|ACHAT|WEB|FACT(URE)?)\b", "", s) s = re.sub(r"[^A-Z0-9 ]", " ", s) s = re.sub(r"\s+", " ", s).strip() return s[:60] ``` ### 7.2 Algorithme ```python PERIODICITIES = [ # (nom, intervalle_min_jours, intervalle_max_jours, intervalle_nominal) ("weekly", 6, 8, 7), ("monthly", 25, 35, 30), # tolérance : prélèvements glissant autour du même jour du mois ("quarterly",85, 97, 91), ("yearly", 350, 380, 365), ] def detect_recurring(user, lookback_months=18) -> list[RecurringSeries]: txs = load_expenses(user, since=today - relativedelta(months=lookback_months), exclude_transfers=True) # dépenses uniquement (amount < 0) groups = group_by(lambda t: (merchant_key(t.label_raw, t.counterparty)), txs) series = [] for key, items in groups.items(): if len(items) < 3 or not key: continue items.sort(key=lambda t: t.booked_date) # dédoublonner les jours multiples (2 achats même jour même commerçant = 1 occurrence datée) dates = sorted({t.booked_date for t in items}) if len(dates) < 3: continue intervals = [ (b - a).days for a, b in zip(dates, dates[1:]) ] med_int = median(intervals) period = next((p for p in PERIODICITIES if p[1] <= med_int <= p[2]), None) if period is None: continue # régularité : au moins 80 % des intervalles dans la fenêtre de la périodicité ok = sum(1 for i in intervals if period[1] <= i <= period[2]) if ok / len(intervals) < 0.8: continue amounts = [abs(t.amount) for t in items] med_amt = median(amounts) # stabilité du montant : écart absolu médian <= max(1 €, 10 % du montant médian) mad = median([abs(a - med_amt) for a in amounts]) if mad > max(Decimal("1.00"), med_amt * Decimal("0.10")): continue series.append(RecurringSeries( merchant_key=key, label_display=most_common(t.label_clean for t in items), category=most_common_category(items), # catégorie majoritaire, sinon None periodicity=period[0], occurrences=len(dates), average_amount=round(mean(amounts), 2), expected_amount=med_amt, # prédiction = médiane (robuste) last_date=dates[-1], next_date_predicted=dates[-1] + timedelta(days=int(round(median(intervals)))), is_active=(today - dates[-1]).days <= period[2] * 2, # inactif si 2 périodes manquées )) series.sort(key=lambda s: (-s.is_active, s.next_date_predicted)) return series ``` Les revenus récurrents (salaire) sont détectés par le même algorithme exécuté sur `amount > 0` (paramètre `direction` de l'endpoint, §9.8). --- ## 8. Agrégats — définitions de calcul ### 8.1 Périmètre commun des statistiques Toutes les stats de dépenses/revenus utilisent le prédicat commun (CTE ou vue SQL `fin_stats_base`) : ```sql SELECT t.*, COALESCE(c_parent.id, c.id) AS root_category_id FROM fin_transactions t LEFT JOIN fin_categories c ON c.id = t.category_id LEFT JOIN fin_categories c_parent ON c_parent.id = c.parent_id WHERE t.user_id = :user_id AND t.currency = 'EUR' AND t.transfer_group_id IS NULL AND (c.kind IS NULL OR c.kind <> 'transfer') AND (:account_ids IS NULL OR t.account_id = ANY(:account_ids)) ``` Mois d'une transaction : `to_char(booked_date, 'YYYY-MM')` — les dates sont civiles, aucune conversion de fuseau. ### 8.2 Rollup de l'arbre de catégories L'arbre étant limité à 2 niveaux, le rollup se fait par le `LEFT JOIN` sur le parent ci-dessus (`root_category_id`). Le total d'une catégorie **racine** = ses transactions directes + celles de tous ses enfants. Une transaction sans catégorie va dans le pseudo-groupe `"uncategorized"` (affiché « Non catégorisé », couleur grise `#9ca3af`). Si l'arbre devait un jour dépasser 2 niveaux, remplacer le join par une CTE récursive — non nécessaire en v1. ### 8.3 Budget vs réalisé (mois M) ``` budget applicable = fin_budgets où start_month <= M et (end_month IS NULL ou end_month >= M) actual(catégorie) = SOMME(ABS(amount)) des dépenses (amount < 0) du périmètre §8.1 pour le mois M sur le sous-arbre de la catégorie budgétée (elle-même + enfants si racine) progress_pct = round(actual / monthly_amount * 100, 1) (peut dépasser 100) remaining = monthly_amount - actual (peut être négatif) projected_eom = mois courant uniquement : actual / jours_écoulés * jours_du_mois (linéaire, arrondi 2 déc.) ``` --- ## 9. API — spécification des endpoints Préfixe commun : **`/api/v1/finance`**. Auth : JWT Bearer obligatoire partout (dépendance FastAPI commune du socle) ; le `user_id` provient exclusivement du token. Erreurs : enveloppe commune du socle `{ "detail": "message en français" }` avec codes 400/401/404/409/413/422. Validation : schémas Pydantic v2 dans `finance/schemas.py`. **Pagination** (listes) : `?page=1&page_size=50` (max 200) → `{ "items": [...], "total": 1234, "page": 1, "page_size": 50 }`. **Dates** : query params en ISO `YYYY-MM-DD` ; mois en `YYYY-MM`. **Montants JSON** : nombres (sérialisation `Decimal` → 2 décimales via encoder Pydantic). ### 9.1 Comptes | Méthode | Route | Description | |---|---|---| | `GET` | `/accounts` | Liste (query `include_archived=false`). Chaque item inclut `balance` (calculé : `initial_balance + SUM(amount)`) et `transaction_count`. | | `POST` | `/accounts` | Corps : `{name, kind, currency?, institution?, iban_masked?, initial_balance?}`. 409 si nom déjà pris. | | `PATCH` | `/accounts/{id}` | Mise à jour partielle des mêmes champs + `is_archived`. | | `DELETE` | `/accounts/{id}` | 409 si transactions existantes (message : « Archivez le compte à la place. »). | ### 9.2 Transactions `GET /transactions` — filtres (tous optionnels, combinés en ET) : | Param | Type | Sémantique | |---|---|---| | `date_from`, `date_to` | date | Sur `booked_date`, bornes incluses. | | `account_id` | UUID, répétable | Multi-comptes. | | `category_id` | UUID ou littéral `none`, répétable | `none` = non catégorisées. Une catégorie **racine** inclut ses enfants. | | `q` | string | `ILIKE '%q%'` (via pg_trgm) sur `label_clean`, `label_raw`, `counterparty`, `notes`. | | `direction` | `debit`\|`credit` | Signe du montant. | | `amount_min`, `amount_max` | number | Sur `ABS(amount)`. | | `is_transfer` | bool | `transfer_group_id IS (NOT) NULL`. | | `import_run_id` | UUID | Transactions d'un import. | | `sort` | string | `booked_date` (défaut, desc), `amount`, `label_clean` ; préfixe `-` pour desc. | Item de réponse : ```json { "id": "…", "account_id": "…", "account_name": "BoursoBank", "booked_date": "2026-08-02", "value_date": null, "amount": -42.90, "currency": "EUR", "label_raw": "CARTE 01/08 CARREFOUR CITY PARIS", "label_clean": "Carrefour City Paris", "counterparty": "Carrefour", "category_id": "…", "category_name": "Courses", "category_color": "#10b981", "category_source": "rule", "notes": null, "transfer_group_id": null, "external_id": null, "import_run_id": "…" } ``` Autres opérations : | Méthode | Route | Description | |---|---|---| | `POST` | `/transactions` | Saisie manuelle : `{account_id, booked_date, amount, label_clean, category_id?, notes?, counterparty?}` ; `label_raw = label_clean`, `category_source = 'user'` si catégorie fournie ; dedup_hash calculé (§4.3, occurrence via requête count sur tuple identique). 409 si collision de hash. | | `PATCH` | `/transactions/{id}` | Champs éditables : `label_clean`, `counterparty`, `category_id` (pose `category_source='user'` ; `null` remet `category_source=NULL`), `notes`, `booked_date`, `amount` (uniquement si saisie manuelle : `import_run_id IS NULL`, sinon 422). | | `DELETE` | `/transactions/{id}` | Uniquement saisie manuelle (`import_run_id IS NULL`), sinon 422 (« Supprimez l'import complet ou ignorez la ligne. »). | | `POST` | `/transactions/bulk-categorize` | `{ "transaction_ids": ["…"], "category_id": "…" }` (max 500 ids) → pose `category_source='user'` sur chaque ; réponse `{ "updated": 42 }`. `category_id: null` = décatégoriser. | ### 9.3 Catégories | Méthode | Route | Description | |---|---|---| | `GET` | `/categories` | Arbre complet : racines avec `children: [...]`, + `transaction_count` par catégorie. | | `POST` | `/categories` | `{name, parent_id?, icon?, color?, kind?}` — kind hérité/forcé si parent ; profondeur max 2 (422). | | `PATCH` | `/categories/{id}` | `name`, `icon`, `color`, `sort_order`, `parent_id` (re-parentage : refusé si la catégorie a des enfants et gagnerait un parent). 422 sur catégories système pour `name`/`kind`. | | `DELETE` | `/categories/{id}` | 422 si `is_system`. Décatégorise les transactions (§2.2), supprime enfants, budgets liés, et nettoie `actions.set_category_id` des règles concernées (action mise à `null` ; règle désactivée si elle devient vide). | ### 9.4 Règles | Méthode | Route | Description | |---|---|---| | `GET` | `/rules` | Triées par `priority ASC`. Inclut `hit_count`, `last_applied_at`. | | `POST` | `/rules` | `{name, priority?, enabled?, stop?, matchers, actions}` — validation des schémas §5.1/§5.2 (regex compilée à la validation ; 422 si invalide). | | `PATCH` | `/rules/{id}` | Mise à jour partielle. | | `DELETE` | `/rules/{id}` | Suppression (les transactions gardent leur catégorie, `applied_rule_id` devient un id orphelin, acceptable). | | `POST` | `/rules/reorder` | `{ "ordered_ids": ["…"] }` → réécrit `priority = index * 10`. | | `POST` | `/rules/apply` | §5.4. Corps : `{scope?, date_from?, date_to?, account_id?, rule_id?, dry_run?, force?}`. | | `POST` | `/rules/preview` | Corps : `{matchers}` (règle non sauvegardée) → 50 premières transactions qui matcheraient + `total_matched`. Sert d'assistant de création depuis une transaction (« créer une règle depuis cette ligne »). | ### 9.5 Budgets | Méthode | Route | Description | |---|---|---| | `GET` | `/budgets` | Query `month=YYYY-MM` (défaut : mois courant) → budgets applicables ce mois, avec `actual`, `progress_pct`, `remaining`, `projected_eom` (§8.3). | | `POST` | `/budgets` | `{category_id, monthly_amount, start_month, end_month?}` — 409 si chevauchement (§2.7), 422 si catégorie non-expense. | | `PATCH` | `/budgets/{id}` | `monthly_amount`, `end_month` ; ou `{monthly_amount, effective_from: "YYYY-MM"}` → clôture + création (§2.7), réponse : les deux budgets. | | `DELETE` | `/budgets/{id}` | Suppression simple. | ### 9.6 Imports & profils de source | Méthode | Route | Description | |---|---|---| | `GET` | `/source-profiles` | Presets intégrés + profils de l'utilisateur (`is_builtin` distingue). | | `POST` | `/source-profiles` | Création d'un profil utilisateur `{name, kind, config}` (config validé par le schéma du kind). | | `POST` | `/source-profiles/{id}/clone` | Clone un preset (ou un profil) vers un profil utilisateur éditable. | | `PATCH` / `DELETE` | `/source-profiles/{id}` | 403 sur les builtins. | | `POST` | `/imports/preview` | Multipart : `file` + `source_profile_id` + `account_id`. Parse **sans écrire** : `{ "rows_preview": [20 premières NormalizedRow sérialisées], "rows_total": 143, "rows_error": 1, "would_skip_duplicates": 21, "date_min": "…", "date_max": "…", "errors": [...] }`. L'UI affiche ce retour avant confirmation. | | `POST` | `/imports` | Multipart identique → exécute §4.4, réponse : l'`ImportRun` complet avec `stats`. | | `GET` | `/imports` | Historique paginé des runs. | | `GET` | `/imports/{id}` | Détail d'un run. | | `DELETE` | `/imports/{id}` | Rollback (§2.4), query `force=true` pour passer outre les modifications manuelles. | ### 9.7 Virements | Méthode | Route | Description | |---|---|---| | `POST` | `/transfers/detect` | Corps optionnel `{date_from?, date_to?}` → `{ "pairs_created": 3 }`. | | `POST` | `/transfers/link` | `{transaction_id_a, transaction_id_b}` (§6). | | `DELETE` | `/transfers/{transfer_group_id}` | Déliaison (§6). | ### 9.8 Statistiques (chart-ready ECharts) Tous ces endpoints acceptent `account_id` (répétable) pour restreindre le périmètre ; défaut = tous les comptes non archivés. Les montants de dépenses sont retournés **en valeur absolue** (positive) — le signe est porté par la sémantique du champ. #### `GET /stats/monthly-by-category?months=12&level=root&direction=debit` Barres empilées par mois. `level` : `root` (rollup §8.2, défaut) | `child` (catégories feuilles). Réponse : ```json { "months": ["2025-09", "2025-10", "…", "2026-08"], "series": [ { "category_id": "…", "name": "Alimentation", "color": "#10b981", "data": [412.50, 388.10, 0, 401.00, "…"] }, { "category_id": null, "name": "Non catégorisé", "color": "#9ca3af", "data": ["…"] } ], "totals": [1830.20, 1795.00, "…"] } ``` `data[i]` correspond à `months[i]` ; mois sans dépense = `0`. Mapping ECharts direct : `xAxis.data = months`, une `series` bar `stack:'total'` par entrée. #### `GET /stats/cashflow?months=12` ```json { "months": ["2025-09", "…"], "income": [2843.00, "…"], "expenses": [1830.20, "…"], "net": [1012.80, "…"], "cumulative_net": [1012.80, 2130.60, "…"] } ``` #### `GET /stats/top-merchants?months=3&limit=15&direction=debit` Groupé par `COALESCE(counterparty, merchant_key(label_clean))` : ```json { "period": { "from": "2026-06-01", "to": "2026-08-31" }, "items": [ { "merchant": "Carrefour", "total": 512.40, "count": 14, "average": 36.60, "category_name": "Courses", "category_color": "#10b981" } ] } ``` #### `GET /stats/recurring?direction=debit&include_inactive=false` Sérialisation directe de §7.2 : ```json { "items": [ { "merchant_key": "NETFLIX", "label_display": "Netflix", "category_id": "…", "category_name": "Abonnements & streaming", "periodicity": "monthly", "occurrences": 14, "average_amount": 13.49, "expected_amount": 13.49, "last_date": "2026-07-28", "next_date_predicted": "2026-08-27", "is_active": true } ], "monthly_total_estimate": 187.40 } ``` `monthly_total_estimate` = somme des `expected_amount` actifs normalisés au mois (weekly ×4.33, quarterly ÷3, yearly ÷12). #### `GET /stats/budget-progress?month=2026-08` ```json { "month": "2026-08", "items": [ { "budget_id": "…", "category_id": "…", "category_name": "Alimentation", "category_color": "#10b981", "budget": 450.00, "actual": 312.40, "remaining": 137.60, "progress_pct": 69.4, "projected_eom": 468.60, "status": "warning" } ], "totals": { "budget": 1650.00, "actual": 1204.10, "progress_pct": 73.0 } } ``` `status` : `ok` (< 80 %), `warning` (80–100 % ou `projected_eom > budget`), `over` (> 100 %). `projected_eom` = `null` pour les mois passés. #### `GET /stats/sankey?month=2026-08` (ou `?months=3` : agrégat de la période) Trois étages : catégories de revenus → nœud central `"Revenus"` → catégories racines de dépenses → catégories enfants (uniquement celles avec dépense > 0). Solde : si revenus > dépenses, lien `Revenus → Épargne du mois` avec l'excédent ; si déficit, nœud `Découvert / réserves → Revenus` avec le manque. Format **directement consommable par `series-sankey` ECharts** (les nœuds sont référencés par `name`, garantis uniques — préfixer un enfant homonyme par « Parent · Enfant ») : ```json { "period": { "from": "2026-08-01", "to": "2026-08-31" }, "nodes": [ { "name": "Salaire", "color": "#3b82f6" }, { "name": "Revenus", "color": "#64748b" }, { "name": "Alimentation", "color": "#10b981" }, { "name": "Courses", "color": "#10b981" }, { "name": "Épargne du mois", "color": "#22c55e" } ], "links": [ { "source": "Salaire", "target": "Revenus", "value": 2843.00 }, { "source": "Revenus", "target": "Alimentation", "value": 412.50 }, { "source": "Alimentation", "target": "Courses", "value": 355.20 }, { "source": "Revenus", "target": "Épargne du mois", "value": 1012.80 } ] } ``` Les transactions non catégorisées apparaissent comme nœud « Non catégorisé » côté dépenses (et « Autres revenus » côté revenus si crédits non catégorisés). Les virements internes sont exclus (§8.1). --- ## 10. Points d'implémentation et cas limites (checklist) 1. **Encodage cp1252** : le mode `"auto"` (§4.1) ne peut pas échouer ; ne jamais utiliser `chardet` (dépendance inutile). 2. **Virgule décimale** : toujours passer par le nettoyage §3.1 avant `Decimal(...)` ; tester `"1 234,56"`, `"1.234,56"` (thousands `.`), `"-12,5"`, `"−12,50"` (U+2212), `"12,50 €"`. 3. **Deux transactions identiques le même jour** : couvertes par `occurrence` (§4.3) — test unitaire obligatoire (même fichier ré-importé = 0 insertion ; fichier avec 2 lignes identiques = 2 insertions). 4. **OFX FITID non fiable chez certaines banques** (FITID régénérés) : la dédup par hash (§4.3) reste le filet de sécurité — l'`external_id` en doublon est ignoré au profit du test de hash si l'`external_id` n'existe pas encore mais que le hash existe. 5. **Ordre du pipeline** : règles appliquées **avant** insertion (une passe), détection de virements **après** insertion (besoin des deux jambes en base). 6. **`category_source = 'user'` est sacré** : aucun traitement automatique (règles, virements) n'écrase une décision manuelle, sauf `force`. 7. **Montants dans les stats** : dépenses en valeur absolue, revenus positifs ; ne jamais additionner des signes mélangés sans filtre `direction`. 8. **Tests de non-régression importeurs** : un fichier d'exemple anonymisé par preset dans `api/tests/fixtures/finance/` (BoursoBank, CA, LBP, SG, Fortuneo, OFX 1.x, OFX 2.x, PayPal FR, PayPal EN) avec snapshot des `NormalizedRow` attendues. 9. **Performance** : volumes attendus < 100 k transactions ; les index définis en §2.5 suffisent. Les stats font des agrégats SQL (jamais de boucle Python sur toutes les transactions), sauf la détection de récurrences (§7) qui charge 18 mois de dépenses (~5 k lignes max, acceptable). 10. **UI française** : libellés d'erreurs API en français (ils remontent tels quels dans l'UI) ; formats d'affichage : dates `dd/MM/yyyy`, montants `1 234,56 €` (espace insécable), gérés côté front par `Intl.NumberFormat('fr-FR', {style:'currency', currency:'EUR'})`.