Case study · finance-tracker

Analyse de relevés bancaires PDF en two-phase tool_use

Une app perso qui ingère des relevés PDF, les fait catégoriser par Claude, calcule un score de santé financière, et propose une démo publique verrouillée pour montrer sans rien fuiter. Stack : React 18 + NestJS 11 + Anthropic SDK, code 100% écrit avec Claude Code.

Aperçu visuel

Captures prises en mode démo (persona "Alex Démo", dataset synthétique — aucune donnée réelle ne sort jamais de la machine, et surtout pas vers un repo public).

Dashboard — patrimoine net, alertes, score de santé
Dashboard — patrimoine net (solde + épargne − dette), alertes revolving proches du plafond, score de santé mensuel.
Santé financière — 4 indicateurs à seuils + conseils IA locale
Santé financière — reste à vivre, charge de dette, dépendance aux tirages, trajectoire d'encours 6 mois. Verdicts calculés en code (seuils configurables) ; les conseils rédigés, eux, viennent d'un LLM 100% local (Ollama).
Dépenses — camembert drill-down, crédits isolés
Dépenses — répartition des retraits avec crédits / abonnements connus / épargne / opérations neutres isolés d'office. Clic sur une part = liste des transactions. L'IA locale propose ensuite des coupes (abonnement / achat ponctuel / autre).
Crédits — classiques, revolving, paiements en N fois
Crédits — prêts classiques (barre temporelle), réserves revolving (jauge d'encours), paiements en N× (Klarna…). Import batch des relevés de crédit et plans d'amortissement, rattachés par n° de contrat.

Pourquoi ce projet

Suivre ses finances perso à la main, c'est lourd : ouvrir chaque relevé, ressaisir chaque transaction, retrouver de tête à quelle catégorie ça appartient. L'enjeu était de voir si un LLM pouvait absorber cette corvée de catégorisation et la rendre fiable — pas juste plausible — sur des PDFs bancaires français bruts.

Sous-objectif personnel : c'était aussi un terrain pour pratiquer NestJS + React 18 + Tailwind + shadcn, hors de la zone JVM dans laquelle je tourne en prod depuis 2005. Le code a été écrit en pair-programming avec Claude Code, ChatGPT a posé le logo et les premières maquettes UX. Aucune ligne tapée en solo.

Architecture

Architecture
Loading diagram…
Pipeline d'import : PDF → NestJS → Claude two-phase → JSON storage. Démo publique passe par un Cloudflare quick tunnel + bypass PIN host-locked.

Trois flux remarquables coexistent : l'import PDF (synchrone, multipart, jusqu'à 12 fichiers en une fois), l'auto-sync post-analyse qui met à jour les jauges crédit / épargne, et le mode démo isolé qui dérive le dataDir runtime via AsyncLocalStorage pour ne jamais croiser les données réelles avec les fixtures publiques.

Stack & data flow

  • Frontend : React 18 + TypeScript 5 + Vite 5 + TanStack Router (code-based) + TanStack Query + Tailwind 3 + shadcn/ui + Recharts. Palette slate + emerald (validée comme identité fintech sobre). font-feature-settings: 'tnum' global pour des chiffres tabulaires partout — non-négociable en fintech.
  • Backend : NestJS 11 + Anthropic SDK 0.91 + Multer pour l'upload + Zod pour la validation. Stockage 100% JSON local — pas de DB. Un fichier par mois sous data/finance/statements/<YYYY-MM>.json, archivage automatique en sous-dossier archive/<YYYY>/ pour les années passées.
  • IA : Claude Sonnet 4.5 en tool_choice: { type: 'tool' } strict pour forcer la sortie structurée. Phase 1 extrait les transactions brutes + score + suggestions de récurrents ; Phase 2 catégorise chaque transaction (catégorie / sous-catégorie / confidence). Découper en deux appels donne un meilleur taux de réussite que tout demander d'un coup.
  • Build & deploy : Docker multi-stage (node:20-alpine builder → nginx:alpine pour le frontend, node:20-alpine runtime pour le backend), docker-compose sur NAS Synology DSM. nginx proxifie /api/ vers le backend sur le même réseau Docker.
  • Auth : PinGuard NestJS global, Bearer token simple comparé à process.env.APP_PIN. Mode permissif si la variable est absente. Frontend stocke le PIN en sessionStorage (volontairement non persistant entre onglets).

Le process avec Claude Code

Construit en 8 phases incrémentales (V3 livrée 2026-05-02, durcissements V3.1 le 2026-05-03), chaque phase écrite en TDD bite-sized via plans markdown que Claude Code génère puis exécute. Mon rôle : tracer la vision, valider à chaque étape, repérer ce qui cloche en testant l'app avec mes propres relevés. Le rôle de Claude : poser le code, expliquer, itérer.

  • Two-phase tool_use Claude — Phase 1 extraction PDF → transactions + score, Phase 2 catégorisation. Le tool_choice strict évite les hallucinations de format ; la validation Zod côté NestJS bloque ce qui passe quand même.
  • Heuristiques de split sub-credits — sur un créancier qui porte plusieurs prêts simultanés, on ne peut pas se contenter du nom. Le code cluster les débits par montant ±5% (absorbe les variations d'intérêts), ne garde que les références à ≥ 8 chiffres qui reviennent ≥ 2 fois, et ne retient un cluster que s'il couvre ≥ 3 mois distincts avec ≤ 1 occurrence par mois. Sans ces filtres, le bruit cartes (PAIEMENT CB, RETRAIT) générait des dizaines de faux crédits.
  • Mode démo Cloudflare host-locked — un middleware regarde le header Host et, s'il matche DEMO_FORCED_HOSTS (par défaut trycloudflare.com), force demoMode = true et bypass PinGuard. La UI lit /api/demo/status au boot ; si forced, elle masque le bouton "Quitter démo" et affiche un badge "Démo verrouillée". N'importe quel quick tunnel ad-hoc devient ainsi public mais isolé sur la persona "Alex Démo" — dataset synthétique 6 mois, jamais croisé avec les vraies données.
  • PinGuard NestJS portable — recette réutilisée telle quelle sur warhammer40k. Pas de hash, pas de JWT : token clair comparé à une env var, sessionStorage côté frontend, prompt natif en cas de 401 et retry. Suffisant pour un usage perso/familial.

2026-08 — santé financière & IA 100% locale

Trois mois de vie réelle plus tard, l'app a changé de nature : d'un visualiseur de relevés, elle est devenue un outil de décision pour sortir d'une situation d'endettement revolving. Principe directeur de cette phase : « les chiffres en code, les mots au LLM » — aucun verdict n'est délégué à une IA, et le LLM utilisé est un modèle local (Ollama + qwen3 sur RTX 5090), donc les libellés bancaires ne quittent jamais la machine.

  • Diagnostic santé à 4 indicateurs — reste à vivre, charge de dette, dépendance aux tirages revolving, trajectoire d'encours 6 mois. Seuils configurables, verdicts déterministes testés ; l'IA locale ne fait que rédiger les conseils. Un test sentinelle verrouille le prompt : le nom de l'employeur ne doit jamais y apparaître.
  • Détection LLM des crédits et paiements en N fois (Klarna, PayPal 4×…) en trois verrous : le LLM local propose, un validateur déterministe vérifie (1 débit/mois max, jour de prélèvement stable ±5j, fraîcheur, nombre d'échéances plausible ∈ {2,3,4,5,6,10,12}), l'humain accepte. Le LLM avait inventé un « 8× » — le validateur l'aurait affiché « estimé », le vrai contrat Zalando a tranché : 3×.
  • Import crédits unifié — un seul bouton accepte relevés de crédit ET plans d'amortissement en vrac. L'extraction identifie le documentType par PDF et route ; un relevé bancaire déposé au mauvais endroit est refusé avec un message qui pointe la bonne page (leçon apprise : avant ce garde-fou, un relevé Sofinco a écrasé le relevé bancaire du même mois — restauré grâce aux snapshots automatiques pré-écriture).
  • Recalibrage épargne robuste au désordre — le solde officiel (section « vos autres comptes » du relevé) prime, mais un relevé plus ancien qu'un recalibrage déjà appliqué ne peut plus « rembobiner » le solde. Et plus d'intérêts estimés par-dessus un solde officiel qui les contient déjà : tout mécanisme de recalage doit être monotone dans l'ordre des périodes.

Pivots & lessons learned

  • Période d'un relevé : ne pas se fier au nom de fichier. Les conventions banque française nomment souvent le PDF avec la date d'émission (mois M+1), pas la période couverte (mois M). Avant fix, extractMonthFromFilename devinait mal et ratait des mois. Solution : ne reconnaître que le pattern explicite YYYY-MM, sinon dériver la période côté code via le nombre de jours distincts (pas le nombre de tx) — un relevé qui chevauche deux mois vote pour celui avec ~21 jours uniques contre ~9.
  • Archivage par déplacement, pas suppression. La V1 supprimait le fichier après agrégation dans le yearly summary. Conséquence : si je ré-uploadais un vieux mois, le summary se régénérait avec un seul mois et écrasait le reste. Fix : déplacer dans archive/<YYYY>/ et régénérer depuis l'ensemble du dossier. Les opérations destructives par défaut, c'est presque toujours une mauvaise idée.
  • Whitelist + regex anti-bruit avant auto-création. Claude suggère parfois "c'est un crédit" sur des paiements CB répétés au même commerçant. Sans KNOWN_LOAN_CREDITORS (organismes REGAFI/ASF) et sans regex NOT_A_CREDIT (PAIEMENT CB|ACHAT CB|RETRAIT…), on créait des crédits fantômes. Leçon : un LLM qui suggère, c'est bien. Un code qui filtre avant d'écrire, c'est mieux.
  • sessionStorage n'est pas réactif. Le toggle mode démo écrivait en sessionStorage mais l'UI ne se rafraîchissait pas. Pas la peine de poser un signal/store autour : un window.location.reload() après écriture règle le problème en 1 ligne. À ne pas sur-architecturer.
  • Distinguer auth error vs quota. Le SDK Anthropic remonte 401 (clé KO) et 429 (quota) dans des classes d'exceptions différentes mais une regex naïve les regroupait. Une clé invalide apparaissait comme "quota épuisé" — message rassurant mais faux. Classification dédiée isQuotaError() qui ne mélange plus.
↗ GitHub Démo verrouillée — Cloudflare quick tunnel (URL éphémère)