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.

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.

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)