Aller au contenu

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 migrations
apps/web l'application web React
packages/shared les types du domaine, le schéma d'import canonique, la monnaie en Decimal,
les fonctions de valorisation et de performance
packages/mcp le serveur MCP qu'un client MCP pilote
docs ce site de documentation

Dans 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.

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.

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 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 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.

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).

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.

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.

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.

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.

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 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), le role (user ou admin), un prénom et un nom facultatifs, le questionnaire investorProfile (JSON), les preferences d’affichage, qui suivent le compte plutôt que le navigateur (JSON), le drapeau isDemo du compte de démonstration, le totpSecret chiffré avec totpEnabled, les empreintes recoveryCodes et le repère totpLastStep qui rend chaque code utilisable une seule fois, et le resetTokenHash à 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 userAgent et une ip facultatifs.
  • 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, avec scopes, expiresAt, lastUsedAt et revokedAt.
  • Account : un compte financier avec un type (checking, savings, cto, pea, av, crypto_wallet, crowdlending, real_estate, loan, object, other), un syncMode (manual ou connector), un connectorMeta qui 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 de tax.ts, plus bas), archivedAt (les comptes fermés restent dans l’historique) et un costBasis facultatif au niveau du compte.
  • Asset : un titre ou une crypto (kind parmi etf, fund, stock ou crypto) avec un isin, un ticker et un coingeckoId facultatifs, les frais courants du fonds (ter) et une composition mise 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 Asset dans un Account, avec quantity, costBasis et les frais cumulés feesTotal.
  • 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 conserve isin et securityName, pour qu’une ligne entièrement vendue garde son identité une fois son lien vers Asset disparu.
  • 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, un type facultatif, fee, tax, un categoryId et un dedupHash unique par compte (la clé de dédoublonnage).
  • DeletedTransaction : la trace d’une opération que le lecteur a supprimée, une par dedupHash et 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 (adoptSeedKeys la 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 breakdown JSON, unique par lecteur et par date ; c’est lui qui alimente les graphiques d’évolution.
  • Goal : un objectif d’épargne nommé, avec un targetAmount et les comptes qui le financent dans accountIds. Le matelas de sécurité en est un, avec un kind valant cushion, au plus un par lecteur, et une cible égale à monthlyExpenses multiplié par monthsCovered plutô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. GoalsService impose cette règle à chaque écriture.
  • Credential : une clé de fournisseur par lecteur, stockée sous forme de ciphertext, iv et authTag. 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 de COINGECKO_DEMO_KEY et OPENFIGI_API_KEY, dans l’environnement, et ces champs promettaient donc une limite de débit plus haute qu’ils ne pouvaient pas offrir. CREDENTIAL_PROVIDERS refuse désormais ces deux noms, tandis que list et remove acceptent 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 dans Account.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 : UserAchievement n’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.

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.

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.

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.

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û.

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é 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.

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.

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.

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.

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.

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.

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 } 400

Une 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.

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.