Architecture
Cette page s’adresse à ceux qui modifient le code : ce que chaque module gère, ce que contient la base, comment une requête est authentifiée, comment une valeur est calculée, et où est défini, une seule fois, un chiffre utilisé à plusieurs endroits. Si vous voulez utiliser Badlen plutôt que le modifier, commencez par l’introduction ; et s’il vous manque une copie qui tourne, voyez Contribuer.
Badlen est un gestionnaire de patrimoine personnel auto-hébergé, organisé en monorepo pnpm :
apps/api l'API NestJS, avec le schéma Prisma et ses migrationsapps/web l'application web Reactpackages/shared les types du domaine, le schéma d'import canonique, la monnaie en Decimal, les fonctions de valorisation et de performancepackages/mcp le serveur MCP qu'un client MCP pilotedocs ce site de documentationDans la base, tout est rattaché à un userId : l’application, conçue pour un utilisateur,
peut en accueillir plusieurs sans réécriture. Les montants sont stockés en Decimal,
jamais en nombres à virgule flottante, et l’euro sert de devise de valorisation.
Ports et adaptateurs
Section intitulée « Ports et adaptateurs »Un module par domaine, les SDK des fournisseurs isolés derrière des adaptateurs, et les calculs communs dans un paquet que l’API et l’application web appellent toutes deux.
Les modules
Section intitulée « Les modules »L’API compte un module NestJS par domaine. app.module.ts en donne la liste, reprise ici
avec le préfixe de chemin de chacun :
| Module | Préfixe |
|---|---|
auth |
/auth |
accounts |
/accounts, /dashboard, /snapshots |
transactions |
/transactions |
movements |
/movements |
categories |
/categories |
budget |
/budget |
analytics |
/analytics |
insights |
/insights |
positions |
/positions |
real-estate |
/real-estate |
fees |
/fees |
scope-analysis |
/analysis |
recommendations |
/recommendations |
goals |
/goals |
profile |
/profile |
course |
/course |
achievements |
/achievements |
credentials |
/credentials |
api-keys |
/api-keys, et la surface /v1 plus bas |
data-io |
/data |
admin |
/admin |
import |
aucun en propre ; appelé par /v1 |
integrations |
aucun ; les adaptateurs de fournisseurs |
crypto |
aucun ; les deux chiffrements |
demo |
aucun ; le jeu de départ et son recalage |
settings |
aucun ; lit et écrit AppSetting |
prisma |
aucun ; le client de base de données |
/health et /version sont déclarés directement sur AppModule et n’appartiennent à
aucun module.
Les adaptateurs
Section intitulée « Les adaptateurs »Les fournisseurs de données externes sont isolés derrière des adaptateurs, dans
apps/api/src/integrations. Les modules du domaine dépendent de ces services plutôt que
des SDK des fournisseurs : on peut donc changer de fournisseur sans toucher à la logique
métier. Les calculs communs (l’arithmétique monétaire, la valorisation, la performance, la
mensualité de prêt de packages/shared/src/loan.ts) se trouvent dans packages/shared et
servent à plusieurs modules.
Un chiffre utilisé à plusieurs endroits
Section intitulée « Un chiffre utilisé à plusieurs endroits »Un chiffre utilisé à plusieurs endroits est lui aussi défini là, et nulle part ailleurs,
pour que l’API et l’application web ne puissent jamais en donner deux versions.
assumptions.ts regroupe ceux que le produit a choisis : le taux de croissance de la
projection, le rendement réel des actions, l’inflation et le taux de retrait, chacun avec
sa source, le passage du cours qui le justifie, ou la mention qu’aucune source n’est
revendiquée. external-rules.ts regroupe ceux que fixe quelqu’un d’autre : le plafond de
taux d’effort du HCSF, les plafonds de versement du PEA, du PEA-PME et des livrets
réglementés, chacun avec le texte qui l’a fixé et sa date d’entrée en vigueur.
Requête et authentification
Section intitulée « Requête et authentification »Comment un appelant est reconnu, ce qui est refusé avant même que le gestionnaire ne s’exécute, et ce qui reste chiffré une fois enregistré.
Les mots de passe sont hachés avec Argon2. À la connexion, l’API émet un jeton d’accès JWT
de courte durée, envoyé dans un cookie HttpOnly, et un jeton de rafraîchissement opaque
dont seule l’empreinte SHA-256 est stockée en base (RefreshToken, avec un userAgent et
une ip pour la liste des sessions). Les jetons de rafraîchissement changent à chaque
utilisation et peuvent être révoqués un par un ou tous ensemble (déconnecter les autres
sessions). Les durées de vie du jeton d’accès et du jeton de rafraîchissement se
configurent (ACCESS_TOKEN_TTL, REFRESH_TOKEN_TTL_DAYS).
Les mots de passe
Section intitulée « Les mots de passe »La politique de mot de passe tient dans un seul objet, packages/shared/src/password.ts,
utilisé par les DTO de l’API et par les trois écrans qui affichent un champ de mot de
passe. Douze caractères au minimum, deux cents au maximum, aucune règle de composition,
aucune expiration, aucune question secrète, tous les caractères Unicode acceptés et rien
de tronqué ; ce fichier cite, pour chaque chiffre, la publication du NIST, de l’ANSSI, de
la CNIL ou de l’OWASP dont il vient, et explique ce qui a été écarté. Le minimum ne
s’applique qu’au moment où l’on choisit un mot de passe, si bien que les comptes créés
sous une règle plus ancienne peuvent toujours se connecter ; le maximum s’applique partout
où un mot de passe est lu, y compris sur les routes qui ne s’en servent que comme preuve,
car sans lui ce serait la taille maximale du corps JSON qui ferait office de limite, et
Argon2 recevrait n’importe quoi.
Une limite de longueur ne peut pas détecter que motdepasse1234! ne vaut rien : les trois
routes où l’on choisit un mot de passe l’évaluent donc aussi.
apps/api/src/auth/password-strength.ts fait tourner zxcvbn-ts dans le service, embarqué,
sans aucun appel réseau, avec les dictionnaires courants et français. En dessous d’une
note de 4, le mot de passe est refusé, et le refus cite le plus long motif prévisible
(autre que la force brute) qu’il contient plutôt que de dire « trop faible » : le lecteur
apprend ce qu’il doit éviter, sans recevoir de méthode toute faite pour en choisir un
autre. Le NIST recommande ici une liste de mots de passe compromis connus ; le choix
consigné dans ce fichier est de noter le mot de passe à la place.
La note ne tient pas compte de l’identité du demandeur, et le serveur ne refuse jamais un
mot de passe parce qu’il ressemble au lecteur. La ressemblance ne donne lieu qu’à un
avertissement : passwordEchoes, dans @badlen/shared, est un simple test d’inclusion que
le navigateur exécute à chaque frappe, et les trois écrans affichent une phrase sous le
champ quand le mot de passe contient le nom d’utilisateur, le nom du produit ou l’année.
Le serveur ne la lit pas, et aucun bouton n’en dépend.
Le second facteur
Section intitulée « Le second facteur »La double authentification est un TOTP facultatif (otplib). Le secret TOTP chiffré et les
empreintes des codes de récupération à usage unique sont stockés sur le User ; quand la
2FA est activée, la connexion se fait en deux temps. Un code est consommé dès qu’il est
accepté : User.totpLastStep enregistre le pas de temps du dernier code utilisé, et tout
code de pas inférieur ou égal est refusé. Un code intercepté ne peut donc pas être rejoué,
même tant qu’il est encore affiché chez le lecteur.
Les gardes, et leur ordre d’exécution
Section intitulée « Les gardes, et leur ordre d’exécution »Un AppThrottlerGuard global passe en premier ; un JwtAuthGuard global protège ensuite
toutes les routes par défaut, une route s’en exemptant avec le décorateur @Public(). La
limite de débit compte trois niveaux, les deux plus stricts remplaçant le budget global
sur les routes d’authentification et l’effacement des données financières :
| Niveau | Budget | Routes concernées |
|---|---|---|
| Global | 300 par minute | toutes les routes, comptées par route et par IP |
| Identifiants | 10 par minute | les huit routes qui vérifient un mot de passe, un code TOTP ou un code de récupération |
| Demandes de compte | 10 par quart d’heure | l’inscription et les deux étapes d’une réinitialisation |
Le budget global est volontairement large, et compté par route et par IP plutôt que pour
toute l’API, ce qui convient à l’auto-hébergement et se resserre facilement en cas
d’exposition publique. Derrière un reverse proxy, l’adresse IP retenue dépend de ce que
déclare l’administrateur, pas de ce qu’affirme l’en-tête : TRUSTED_PROXY_HOPS indique
combien de proxies de confiance précèdent l’application, et common/throttling.ts explique
quelle adresse de la chaîne X-Forwarded-For il lit alors, et ce qu’il fait si la chaîne
est plus courte que prévu. La liste des sessions, sous Réglages puis Sécurité, applique la
même règle à l’adresse qu’elle enregistre : un visiteur qui pourrait choisir cette adresse
pourrait se connecter en se faisant passer pour le propriétaire, sur l’écran même qui sert
à le repérer.
Chiffré au repos
Section intitulée « Chiffré au repos »Deux informations sont chiffrées au repos en AES-256-GCM, avec la clé
SECRET_ENCRYPTION_KEY : le secret TOTP d’un compte où la 2FA est activée, et les clés API
de fournisseur qu’un lecteur colle dans l’écran des réglages. L’adresse d’un portefeuille
crypto n’en fait pas partie : publique par nature, elle est stockée en clair.
Administration et compte de démonstration
Section intitulée « Administration et compte de démonstration »Chaque utilisateur a un role. AdminGuard réserve /api/admin à role === 'admin', et
au démarrage, s’il n’existe aucun administrateur, le module d’administration promeut le
plus ancien compte hors démonstration : le propriétaire d’une installation mono-utilisateur
existante n’est donc jamais laissé dehors. L’écran d’administration liste les utilisateurs
avec leur activité (comptes, opérations, sessions ouvertes, clés API, état de la 2FA), et
active ou désactive les inscriptions via AppSetting, qui l’emporte sur
REGISTRATION_ENABLED.
Le compte de démonstration est un utilisateur ordinaire marqué isDemo, créé au démarrage
sous DEMO_SEED_ENABLED avec un portefeuille multi-actifs fictif, et recalé sur le jour
présent à chaque démarrage. La production ignore cette variable : le compte existe dans la
démo en ligne, dont le serveur tourne dans l’onglet du
visiteur, et sur les instances où tournent la suite e2e et les balayages de tools/.
POST /auth/demo ouvre sa session là où il est installé, et elle est refusée partout ailleurs.
Les captures d’écran du README viennent de ce compte.
Le modèle de données
Section intitulée « Le modèle de données »Le schéma Prisma (apps/api/prisma/schema.prisma) s’organise autour du User, à qui
appartiennent toutes les autres entités, par des relations en cascade.
- User : les identifiants (
passwordHash), lerole(userouadmin), un prénom et un nom facultatifs, le questionnaireinvestorProfile(JSON), lespreferencesd’affichage, qui suivent le compte plutôt que le navigateur (JSON), le drapeauisDemodu compte de démonstration, letotpSecretchiffré avectotpEnabled, les empreintesrecoveryCodeset le repèretotpLastStepqui rend chaque code utilisable une seule fois, et leresetTokenHashà usage unique, avec sa date d’expiration, pour réinitialiser le mot de passe. - RefreshToken : les jetons de rafraîchissement des sessions, hachés et renouvelés à
chaque usage, avec un
userAgentet uneipfacultatifs. - ApiKey : des clés révocables, aux droits limités, pour la surface
/v1; essentiellement des portées de lecture, plus les deux portées d’écriture dont les outils MCP ont besoin. Seuls l’empreinte SHA-256 et un préfixe d’affichage sont stockés, avecscopes,expiresAt,lastUsedAtetrevokedAt. - Account : un compte financier avec un
type(checking, savings, cto, pea, av, crypto_wallet, crowdlending, real_estate, loan, object, other), unsyncMode(manual ou connector), unconnectorMetaqui contient en clair la chaîne et l’adresse d’un portefeuille,openedAt(la date à partir de laquelle se compte la durée de détention d’un PEA ou d’une assurance-vie, les seuils eux-mêmes venant detax.ts, plus bas),archivedAt(les comptes fermés restent dans l’historique) et uncostBasisfacultatif au niveau du compte. - Asset : un titre ou une crypto (
kindparmi etf, fund, stock ou crypto) avec unisin, untickeret uncoingeckoIdfacultatifs, les frais courants du fonds (ter) et unecompositionmise en cache à la première demande (pondérations sectorielles et principales lignes), avec la date de sa récupération. - Holding : une ligne d’un
Assetdans unAccount, avecquantity,costBasiset les frais cumulésfeesTotal. - Trade : un lot daté d’achat ou de vente (
side,quantity,unitPrice,amount,fee), qui retrace l’historique des lots d’une ligne et sa quantité réelle au fil du temps. Il conserveisinetsecurityName, pour qu’une ligne entièrement vendue garde son identité une fois son lien versAssetdisparu. - ManualBalance : des soldes datés pour les comptes simples (courants, livrets), uniques par compte et par date.
- Transaction : une opération signée (un montant négatif est une sortie d’argent) avec
un
label, untypefacultatif,fee,tax, uncategoryIdet undedupHashunique par compte (la clé de dédoublonnage). - DeletedTransaction : la trace d’une opération que le lecteur a supprimée, une par
dedupHashet par compte. C’est elle qui empêche un import ultérieur de recréer l’opération ; comme celle-ci n’existe plus, c’est une copie de l’empreinte, et non une clé étrangère. - Category et CategoryRule : un arbre de catégories propre à chaque lecteur, et des
règles de sous-chaîne qu’il définit lui-même (comparées sans tenir compte des accents ni
de la casse), appliquées avant le jeu de mots-clés générique. Une catégorie issue de
l’arbre par défaut porte une
seedKey, unique par lecteur, et c’est par elle que le jeu de mots-clés et l’exclusion des catégories hors dépenses désignent la catégorie : le nom appartient au lecteur, qui peut le changer, et désigner une catégorie par son libellé créait un doublon à chaque renommage. La clé est déduite plutôt que déclarée (adoptSeedKeysla tire du nom de la catégorie et de la forme de ses sous-catégories) : une catégorie créée par le lecteur en reçoit une quand son contenu indique à quelle branche de l’arbre elle correspond, et une clé nulle signifie que rien n’a permis de l’identifier, pas que le lecteur l’a créée. - PriceHistory : des cours datés par actif et par devise, uniques par actif, date et devise.
- NetWorthSnapshot : un point quotidien de patrimoine brut, de passif et de patrimoine
net, avec un
breakdownJSON, unique par lecteur et par date ; c’est lui qui alimente les graphiques d’évolution. - Goal : un objectif d’épargne nommé, avec un
targetAmountet les comptes qui le financent dansaccountIds. Le matelas de sécurité en est un, avec unkindvalantcushion, au plus un par lecteur, et une cible égale àmonthlyExpensesmultiplié parmonthsCoveredplutôt qu’à un montant propre. Un compte finance au plus un objectif : le lier ailleurs le retire du précédent, ce qui empêche de compter un même euro pour deux cibles.GoalsServiceimpose cette règle à chaque écriture. - Credential : une clé de fournisseur par lecteur, stockée sous forme de
ciphertext,ivetauthTag. L’écran des réglages en propose une, Moralis, utilisée à la synchronisation d’un portefeuille. Il proposait aussi CoinGecko et OpenFIGI, mais ces deux lignes n’ont jamais été lues : les clés réellement envoyées à ces deux API viennent deCOINGECKO_DEMO_KEYetOPENFIGI_API_KEY, dans l’environnement, et ces champs promettaient donc une limite de débit plus haute qu’ils ne pouvaient pas offrir.CREDENTIAL_PROVIDERSrefuse désormais ces deux noms, tandis quelistetremoveacceptent toujours n’importe quel nom : une ligne écrite par une version plus ancienne reste affichée et peut être supprimée, au lieu de disparaître à l’insu de son propriétaire. L’adresse d’un portefeuille n’est pas une donnée de cette nature et n’est pas stockée ici : elle figure en clair dansAccount.connectorMeta. - CourseProgress et UserAchievement : ce que le lecteur a lu et répondu dans le
cours, et la date de déblocage des paliers de progression. Un critère que les chiffres
du lecteur suffisent à établir est réévalué à chaque lecture :
UserAchievementn’est donc jamais la référence pour un critère. Il date le déblocage, retient si la célébration a été affichée, et enregistre les faits que seul le navigateur connaît (un thème choisi, un simulateur ouvert). - AppSetting : une petite table clé/valeur pour les réglages d’instance qu’un administrateur peut modifier à chaud (pour l’instant, l’ouverture des inscriptions) ; c’est pourquoi chaque réglage l’emporte sur sa valeur par défaut définie dans l’environnement.
Les chiffres, et où chacun est calculé
Section intitulée « Les chiffres, et où chacun est calculé »Chaque nombre affiché par le produit sort de l’un des services ci-dessous, et chacun indique ses hypothèses et ce qu’il refuse de deviner.
Valorisation, historique et rattrapage
Section intitulée « Valorisation, historique et rattrapage »La couche de valorisation calcule la valeur en euros de chaque compte (les lignes
valorisées à partir de PriceHistory, les soldes saisis pris tels quels), puis en déduit
le patrimoine net (le brut moins les passifs, comme les prêts) et la répartition par
actif, par type ou par classe, en réutilisant les fonctions Decimal de packages/shared.
Un service d’historique enregistre un NetWorthSnapshot chaque jour (via une tâche
planifiée, après chaque écriture, et sur demande par une route). Comme on ne peut pas
créer après coup un point réel, une étape de rattrapage reconstitue l’historique : elle
récupère les cours passés (CoinGecko pour les cryptos, Yahoo pour les ETF) et recalcule
les points anciens en valorisant les lignes aux cours de l’époque, avec les quantités
réellement détenues à chaque date d’après Trade, quand elles existent. Le rattrapage
n’écrase jamais un point réel.
Chaque point reconstitué enregistre donc combien de ses lignes reposent sur une
hypothèse (breakdown.quantities), de même qu’un point réel enregistre la fraîcheur des
cours utilisés (breakdown.pricing). snapshot-pricing.ts relit les deux : seriesGapOf
repère les points affichés calculés alors qu’un cours manquait, seriesAssumptionOf ceux
qui reflètent des quantités jamais vraiment détenues, et tout code qui lit l’historique
rend compte des deux (vérifié par snapshot-history.test.ts). Un historique fait de soldes
saisis et datés est exact, et n’appelle aucune réserve.
Une seule devise, et ses limites
Section intitulée « Une seule devise, et ses limites »Rien ici ne convertit de devises : il n’existe aucune source de taux de change.
VALUATION_CURRENCY, dans common/valuation-currency.ts, désigne la devise unique de tous
les totaux, et tout code qui lit PriceHistory filtre sur elle : une clôture en dollars
ne peut jamais s’ajouter à un total en euros. Les cours sont stockés dans la devise
indiquée par la source, et Asset.currency enregistre cette devise à l’arrivée du premier
cours, seul moment où elle est connue.
En conséquence, un titre coté dans une autre devise est une ligne qu’aucun total ne peut
valoriser, et cela ne changera pas. Elle est comptée dans unpricedHoldings comme toute
autre ligne sans cours, avec sa devise : chaque compte et chaque bloc de valorisation
indique foreignCurrency, chaque ligne de position indique quoteCurrency, et une mise à
jour signale une telle ligne sous foreignCurrency plutôt que comme mise à jour. Un
compte déclaré dans une autre devise est refusé à la création, sans quoi ses soldes
seraient ajoutés au total un pour un. price-currency.test.ts exige une décision écrite
de tout fichier qui touche à une ligne de cours, et interdit de filtrer sur une devise
écrite en dur.
Immobilier et prêts
Section intitulée « Immobilier et prêts »L’immobilier et les prêts sont des comptes ordinaires, complétés par une fiche dédiée :
real-estate enregistre les caractéristiques du bien et les conditions du prêt, calcule
le tableau d’amortissement (capital et intérêts, date de fin, capital remboursé) et
alimente la valorisation. Les prêts sont des passifs : le patrimoine net est donc le brut
moins leur capital restant dû.
Les frais
Section intitulée « Les frais »Le module des frais additionne ce qui a réellement été payé (les frais saisis par ligne,
plus fee et tax sur les opérations) et y ajoute les frais courants de chaque fonds
pour projeter le coût dans le temps. Tout montant d’euros payés porte sur le portefeuille
investi, défini dans apps/api/src/fees/invested-book.ts : les lignes d’un compte-titres
qui sont un fonds, un ETF ou une action, les frais et taxes des opérations de ces comptes,
et le courtage de leurs ordres datés. Les deux critères sont nécessaires : la catégorie du
compte seule ferait entrer une crypto achetée dans un CTO dans un total auquel elle
n’apporte aucune valeur. Les frais courants sont récupérés auprès de la source de cours
quand elle les publie, et saisis à la main sinon ; ils sont donnés à titre indicatif,
puisqu’un TER est déjà pris en compte dans le cours du fonds.
Ce que prélève la plateforme elle-même (droits de garde, frais de tenue de compte)
apparaît sur un relevé, mais sur aucune opération : le lecteur déclare donc des frais
annuels par compte, Account.platformFeeAnnual en euros ou
Account.platformFeeAnnualPct en pourcentage de la valeur du compte, jamais les deux, et
nuls tant qu’il n’a rien saisi. Les frais de plateforme déclarés sont le seul bloc qui
liste encore tous les comptes. Aucun barème de banque ou de courtier n’est fourni, ni
comme valeur par défaut ni comme exemple, car ils diffèrent d’une plateforme à l’autre et
se renégocient. Les frais annuels entrent dans la projection à vingt ans, un pourcentage
étant appliqué à la valeur actuelle du compte telle que la calcule ValuationService. Il
n’y a pas de frais par opération : la commission réellement payée figure sur chaque
ordre. Les frais annuels n’entrent pas dans les frais payés : un tarif déclaré n’est pas
une dépense enregistrée. Un compte pour lequel rien n’est déclaré est compté comme non
renseigné, et présenté comme tel : un total que le module ne peut pas compléter ne passe
jamais pour complet.
La fiscalité d’une plus-value
Section intitulée « La fiscalité d’une plus-value »La fiscalité française d’une plus-value est définie une seule fois, dans
packages/shared/src/tax.ts : les prélèvements sociaux, l’impôt dû avant le seuil de
durée d’une enveloppe, l’impôt restant une fois ce seuil franchi, et les deux seuils
eux-mêmes. Jusqu’au 6 septembre 2026, elle était écrite à treize endroits, dont huit dans
du code exécuté, et deux d’entre eux se contredisaient déjà sur une assurance-vie de plus
de huit ans. Tout code qui affiche ces chiffres passe désormais par cette table :
l’estimation sur l’onglet d’un compte, la carte de durée de détention du moteur de
conseils, et les taux de départ du simulateur de patrimoine. Un seul endroit garde
volontairement ses chiffres en dur : le contenu pédagogique, course.json et learn.json
dans les deux langues, dont les chapitres expliquent ce qu’est un taux et d’où il vient,
sans rien calculer. Une partie de ces fichiers est tout de même vérifiée : les deux
entrées du glossaire qui donnent les chiffres sans autre explication sont comparées à la
table par apps/web/src/i18n/taxRates.test.ts, si bien que modifier un taux sans les
modifier fait échouer un test, tandis que le texte des chapitres reste à mettre à jour à
la main. Le commentaire en tête de tax.ts justifie cette exception : lisez-le avant de
modifier un chiffre.
Ces taux sont une valeur par défaut que chaque compte peut remplacer. Trois colonnes
facultatives du compte (taxSocialPct, taxIncomePct, taxMaturedIncomePct) les
remplacent une à une, une valeur nulle signifiant « le cas général » et jamais « rien à
payer », et la carte qui affiche l’estimation indique les taux appliqués. Le reste du
régime n’est volontairement pas modélisé : la date de chaque versement (la réforme de 2017
s’applique à partir du 27 septembre 2017, versement par versement et non contrat par
contrat), le foyer dont dépend l’abattement annuel, et le total versé sur tous les
contrats que le lecteur détient, où qu’ils soient. Aucune de ces trois informations ne
figure dans la base, et l’estimation n’en devine aucune.
Conseils et contenu pédagogique
Section intitulée « Conseils et contenu pédagogique »Le module des conseils tire ses cartes des agrégats du lecteur (épargne de précaution, concentration, part de liquidités, niveau de frais, etc.). Rien n’est téléchargé, rien n’est stocké : les règles s’appliquent au moment de la lecture.
L’espace pédagogique (le cours, les concepts et leur vocabulaire, la pyramide des
placements) est entièrement géré dans le navigateur. Il se trouve dans les espaces de
noms i18n course et learn, exclus des langues chargées au démarrage et enregistrés
depuis le module Apprendre, chargé à la demande, pour que la page de connexion reste
légère.
Fournisseurs, import et API publique
Section intitulée « Fournisseurs, import et API publique »Tout ce qui vient de l’extérieur ou en sort : les sources des cours, la route unique par laquelle arrive un envoi d’opérations, et la surface aux droits limités qu’un programme peut piloter.
Intégrations
Section intitulée « Intégrations »Les fournisseurs externes sont utilisés sans clé quand c’est possible, avec des clés facultatives pour relever les limites de débit.
| Donnée | Source | Clé |
|---|---|---|
| Soldes BTC | mempool.space, blockstream.info | aucune |
| Solde ETH natif | un RPC public | aucune |
| Jetons ERC-20 | Moralis | celle du lecteur |
| Cours et historique des ETF | OpenFIGI, puis Yahoo | OPENFIGI_API_KEY facultative |
| Composition des fonds | Yahoo quoteSummary |
aucune |
| Cours et historique récent des cryptos | CoinGecko, en euros | COINGECKO_DEMO_KEY facultative |
| Historique crypto au-delà de 365 jours | bougies quotidiennes Binance en euros | aucune |
Un solde BTC se calcule comme les sorties reçues moins les sorties dépensées. OpenFIGI
convertit un ISIN en symbole Yahoo, en ajoutant le suffixe de la place de cotation, par
exemple .PA pour Euronext Paris, et Yahoo fournit le cours et l’historique. La clé
Moralis est celle que le lecteur colle dans l’écran des réglages ; les deux autres
viennent uniquement de l’environnement.
L’offre gratuite de CoinGecko ne remonte que sur 365 jours : pour la période antérieure, le rattrapage interroge donc Binance, page par page, dans la limite d’un budget d’appels. Binance n’est jamais sollicitée pour le cours du jour, ni utilisée comme seule source : si elle ne répond pas, le rattrapage se contente de l’année fournie par CoinGecko et signale la période manquante comme indisponible, au lieu de renvoyer en silence une courbe plus courte.
La chaîne d’import
Section intitulée « La chaîne d’import »L’API ne lit aucun fichier. L’import reçoit des opérations canoniques que l’appelant a
déjà extraites, et seule la surface /api/v1 aux droits limités, décrite plus bas, le
propose : POST /v1/import/reconcile est un passage à blanc qui n’écrit rien et indique
ce que ferait un envoi, POST /v1/import l’enregistre. Les deux valident les données avec
le schéma canonique (zod) de packages/shared et signalent les opérations déjà présentes
sur le compte ; reconcile signale en plus les quasi-doublons au libellé modifié, et les
questions qu’un appelant devrait d’abord poser au lecteur. À l’enregistrement, les
opérations sont insérées avec createMany et skipDuplicates, en s’appuyant sur la
contrainte d’unicité (accountId, dedupHash) : envoyer deux fois le même envoi ne pose
aucun problème, et une opération que le lecteur a supprimée après un import précédent est
bloquée par sa trace de suppression au lieu d’être recréée.
Une opération est identifiée par batchDedupKeys (packages/shared/src/canonical.ts) : sa
date, son montant et son libellé, plus son rang parmi les opérations identiques du même
envoi. C’est ce rang qui permet de garder deux opérations identiques le même jour, ce qui
arrive souvent sur un relevé ; il entre dans l’empreinte et jamais dans le libellé, qui
appartient au lecteur. Le passage à blanc et l’enregistrement appellent cette même
fonction sur le même envoi : un appelant qui vérifie un envoi avant de l’écrire vérifie
exactement ce qui sera écrit. Un appelant ne peut pas fournir sa propre empreinte.
Rien ici n’analyse de CSV ni n’extrait quoi que ce soit d’un relevé : la lecture revient
au client MCP. Votre client MCP analyse les relevés avec le modèle que vous payez ou
faites déjà tourner, puis appelle les deux routes ci-dessus ; aucune clé n’est donc à
configurer pour cela dans Badlen, et ImportService ne dépend de rien d’autre que Prisma.
L’API /v1
Section intitulée « L’API /v1 »Une surface publique aux droits limités se trouve sous /api/v1 (apps/api/src/api-keys).
Les requêtes s’authentifient avec un jeton ApiKey en bearer plutôt qu’avec le cookie de
session : un ApiKeyGuard vérifie la clé, contrôle les @Scopes exigées et court-circuite
le garde JWT.
| Portée | Donne accès à |
|---|---|
dashboard:read |
le patrimoine net et le tableau de bord |
accounts:read |
les comptes |
insights:read |
les analyses |
transactions:read |
les opérations |
prices:read |
les cours |
positions:read |
les lignes |
analytics:read |
les statistiques |
accounts:write |
créer un compte, saisir un solde, ajouter une ligne |
transactions:write |
importer des opérations, et le passage à blanc préalable |
Les deux portées d’écriture sont celles qu’utilise le serveur MCP de packages/mcp. Les
clés se créent dans l’écran des réglages, ne s’affichent qu’une fois, ne sont stockées que
sous forme d’empreinte SHA-256, et peuvent avoir une date d’expiration et être révoquées.
La surface est décrite route par route dans la référence de l’API
REST de ce site et dans sa version anglaise : le verbe,
le chemin, la portée exigée, le corps accepté et le format de la réponse. Les deux pages
sont générées à partir du contrôleur par pnpm api:reference, et régénérées par la suite
de tests de l’API à chaque pnpm test : c’est la seule description de ces routes qui ne
puisse pas prendre de retard sur elles.
Chaque total renvoyé par cette surface s’accompagne d’un bloc pricing qui indique sur
combien de lignes il repose et l’âge du cours le plus ancien, pour qu’un appelant puisse
distinguer une panne d’un portefeuille qui a vraiment fondu.
Les montants dans les requêtes
Section intitulée « Les montants dans les requêtes »Envoyez chaque montant et chaque quantité sous forme de chaîne décimale, avec un point comme séparateur décimal et sans séparateur de milliers :
{ "amount": "1234.56", "quantity": "0.123456789012345678" } accepté{ "amount": 1234.56 } 400Une chaîne est enregistrée dans sa colonne Decimal exactement telle qu’elle est écrite.
Le nombre de décimales accepté pour chaque champ est celui de la colonne correspondante
(deux pour un montant, dix-huit pour une quantité détenue, quatre pour un taux) ; les
formats se trouvent dans apps/api/src/common/decimal-input.ts, un par type de colonne.
Exploiter et modifier
Section intitulée « Exploiter et modifier »Cette page s’en tient au code. Le reste du site explique comment exploiter ce qui est
décrit ici : Configuration présente le fichier compose et
l’environnement qu’il lit, Mettre à jour une instance
explique comment passer à une nouvelle image sans perdre le volume, et
Sauvegarder décrit l’export JSON, la copie pg_dump et
ce qu’aucun des deux ne contient.
.github/CONTRIBUTING.md
explique comment modifier le code : l’installation locale, les contrôles de qualité, et les
règles de migration qui empêchent le schéma décrit plus haut de s’écarter de la base.