Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
61 KiB
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 :
- 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). - Liste unifiée de transactions multi-comptes, avec déduplication robuste.
- Catégorisation automatique par moteur de règles rejouable.
- Budgets mensuels par catégorie, avec suivi budget vs réalisé.
- Détection de virements internes (exclus des statistiques de dépenses).
- Détection de dépenses récurrentes (abonnements, prélèvements) avec prédiction de la prochaine échéance.
- 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 versusers.id, table commune du socle). Toutes les requêtes filtrent paruser_id(extrait du JWT). Aucune donnée partagée entre utilisateurs. - Clés primaires :
UUIDgénérés côté base viagen_random_uuid()(extensionpgcryptodéjà activée par le socle). - Horodatage :
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),updated_at TIMESTAMPTZ NOT NULL DEFAULT now()(trigger ouonupdateSQLAlchemy) sur toutes les tables. - Montants :
NUMERIC(12,2)signé. Convention : négatif = débit/dépense, positif = crédit/revenu. Côté Python :decimal.Decimalexclusivement (jamaisfloat). 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 (champexcluded_foreign_currencydans 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éesfin_pour éviter les collisions inter-modules (fin_accounts,fin_transactions, …). Les modèles SQLAlchemy vivent dansapi/app/modules/finance/models.py.
1.2 Diagramme entités-relations
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
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 desenum.StrEnuméquivalents dansapi/app/modules/finance/enums.py(ex.AccountKind.CHECKING = "checking").
2.1 fin_accounts — comptes
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_balancepermet 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 CASCADEexiste en base par sécurité, mais l'API bloque en amont.)
2.2 fin_categories — catégories (arbre)
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
kindde 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égoriestransfer. - La suppression d'une catégorie remet à
NULLlecategory_iddes transactions concernées (fait explicitement par l'API :UPDATE fin_transactions SET category_id = NULL, category_source = NULL WHERE category_id IN (...)avant leDELETE), et supprime les budgets et met àNULLles 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
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
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) :
{
"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 typeAuthorization, voir §3.3).errorsest 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
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_trgmdans la migration (CREATE EXTENSION IF NOT EXISTS pg_trgm;) pour la recherche plein-texte approximative surlabel_clean(ILIKE '%...%'performant). applied_rule_idest 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_hashcalculé 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 NULLa toujourscategory_id= catégorie système « Virements internes » etcategory_sourceconservé tel quel (rule/user/NULL selon origine du marquage).
2.6 fin_rules — règles de catégorisation
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
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'APIPUTpropose ce comportement via le paramètreeffective_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) :
{
"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, sinonrows_error. - Montants : retirer
thousands_separatoret les espaces, remplacerdecimal_separatorpar., retirer un éventuel symbole€ouEUR, parser enDecimal. 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 :
{
"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 selonCHARSETsi présent, sinonfallback_encoding. - OFX 2.x (XML) : prologue XML standard, encodage déclaré dans le prologue.
- Champs extraits par transaction (bloc
<STMTTRN>) :DTPOSTED→booked_date. FormatYYYYMMDDéventuellement suivi deHHMMSS[.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 = NAMEsiMEMOvide, sinonNAME + " — " + MEMO(siNAMEabsent :MEMOseul).TRNTYPE: ignoré pour le montant (déjà signé), mais concaténé nulle part ; conservé uniquement si besoin futur.CURDEFdu relevé →currency.
- Fallback regex (OFX 1.x mal formé, très courant) : découper sur
<STMTTRN>, extraire chaque champ parr"<FIELD>([^<\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 :
{
"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'objetsi non vide).counterparty=Nomdirectement.amount= colonneNet(signée par PayPal : paiement envoyé = négatif).currency= colonneDevise.- 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 êtrestring | 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
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 :
@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 :
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()
occurrenceré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_idpar défense en profondeur, même si la contrainte est déjà scopée compte. label_cleaninitial = normalisation légère delabel_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)
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).
{
"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_maxen valeur absolue avec le sélecteurdirection; l'API stocke les bornes signées telles quelles.
5.2 Schéma JSON de actions
Au moins une clé non nulle exigée.
{
"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
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 :
{ "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).
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}— remettransfer_group_id = NULLsur les deux jambes etcategory_id = NULL, category_source = NULLsi la catégorie était « Virements internes » posée parrule. - 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 prochaindetect_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 :
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
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) :
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 :
{ "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 :
{
"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
{ "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)) :
{ "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 :
{ "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
{ "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 ») :
{
"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)
- Encodage cp1252 : le mode
"auto"(§4.1) ne peut pas échouer ; ne jamais utiliserchardet(dépendance inutile). - 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 €". - 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). - 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_iden doublon est ignoré au profit du test de hash si l'external_idn'existe pas encore mais que le hash existe. - Ordre du pipeline : règles appliquées avant insertion (une passe), détection de virements après insertion (besoin des deux jambes en base).
category_source = 'user'est sacré : aucun traitement automatique (règles, virements) n'écrase une décision manuelle, saufforce.- Montants dans les stats : dépenses en valeur absolue, revenus positifs ; ne jamais additionner des signes mélangés sans filtre
direction. - 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 desNormalizedRowattendues. - 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).
- 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, montants1 234,56 €(espace insécable), gérés côté front parIntl.NumberFormat('fr-FR', {style:'currency', currency:'EUR'}).