Architecture
This page is for whoever is changing the code: what each module owns, what the database holds, how a request is authenticated, how a value is computed, and where a figure read on more than one side is written down once. If you came here to use Badlen rather than to change it, start with the introduction; if what you need is a checkout that runs, see Contributing.
Badlen is a self-hosted personal wealth manager built as a pnpm monorepo:
apps/api NestJS API, with the Prisma schema and its migrationsapps/web React web apppackages/shared domain types, the canonical import schema, Decimal money, valuation and performance helperspackages/mcp the MCP server an MCP client drivesdocs this documentation siteEverything is scoped by userId in the database, so the mono-user app can become multi-user
without a rewrite. Amounts are stored as Decimal, never as floats, with EUR as the valuation
base.
Ports and adapters
Section titled “Ports and adapters”One module per domain, provider SDKs kept behind adapters, and shared arithmetic in one package both ends call.
The modules
Section titled “The modules”The API is organized as one NestJS module per domain. app.module.ts lists them; here they are,
with the path prefix each one answers on:
| Module | Answers on |
|---|---|
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, and the /v1 surface below |
data-io |
/data |
admin |
/admin |
import |
nothing of its own; driven by /v1 |
integrations |
nothing; the provider adapters below |
crypto |
nothing; the two ciphers |
demo |
nothing; the seed and its re-anchor |
settings |
nothing; reads and writes AppSetting |
prisma |
nothing; the database client |
/health and /version are declared on AppModule itself and belong to no module.
The adapters
Section titled “The adapters”External data providers are isolated behind adapters under apps/api/src/integrations. Domain
modules depend on those adapter services rather than on provider SDKs directly, so a provider can
be replaced without touching business logic. Shared calculation logic (money arithmetic,
valuation, performance, the loan instalment of packages/shared/src/loan.ts) lives in
packages/shared and is reused by several modules.
A figure read on more than one side
Section titled “A figure read on more than one side”A figure read on more than one side lives there too, and only there, so the API and the web app
can never state it differently. assumptions.ts holds the ones the product chose: the
projection’s growth rate, the equity real return, inflation and the withdrawal rate, each saying
where it comes from, the course passage anchoring it or that no source is claimed.
external-rules.ts holds the ones somebody else sets: the HCSF debt-service ceiling, the PEA and
PEA-PME contribution caps and the per-product caps on regulated savings, each with the text that
set it and the day it took effect.
Request and authentication flow
Section titled “Request and authentication flow”How a caller is recognised, what is refused before the handler runs, and what is kept encrypted once it is stored.
Authentication uses Argon2 password hashing. On login the API issues a short-lived JWT access
token delivered as an HttpOnly cookie, plus an opaque refresh token whose SHA-256 hash is stored
in the database (RefreshToken, with a userAgent and an ip for the sessions list). Refresh
tokens rotate on use and can be revoked individually or in bulk (revoke other sessions). Access
token lifetime and refresh lifetime are configurable (ACCESS_TOKEN_TTL,
REFRESH_TOKEN_TTL_DAYS).
Passwords
Section titled “Passwords”The password policy is one object, packages/shared/src/password.ts, read by the API’s DTOs and
by the three screens that draw a password field. Twelve characters at the shortest and two hundred
at the longest, no composition rule, no expiry, no security questions, every Unicode character
accepted and nothing trimmed; that file cites the NIST, ANSSI, CNIL and OWASP publications each
figure comes from and says what was rejected. The minimum applies only where a password is chosen,
so accounts created under an older rule keep signing in; the maximum applies everywhere one is
read, including the routes that only take it as proof, because without it the JSON body limit is
the real bound and Argon2 is handed whatever arrives.
A length bound cannot say that motdepasse1234! is worthless, so the three routes that let a
password be chosen also score it. apps/api/src/auth/password-strength.ts runs zxcvbn-ts in the
service, embedded and with no network call on any path, against the common and French
dictionaries. Below a score of 4 the password is refused, and the refusal names the longest
non-bruteforce pattern in it rather than saying “too weak”, so the reader is told what to stop
doing and nothing else: it does not go on to recommend a way of choosing another one. NIST’s
requirement here is a blocklist of known compromised passwords; the choice recorded in that file
is the scoring option instead.
The score is not told who is asking, and nothing on the server refuses a password for resembling
the reader. Resemblance is a warning and nothing more: passwordEchoes in @badlen/shared is a
plain containment test the browser runs on every keystroke, and the three screens print one
sentence under the field when the password holds the username, the product’s name or the year.
Nothing on the server reads it and no button waits on it.
The second factor
Section titled “The second factor”Two-factor authentication is optional TOTP (otplib). The encrypted TOTP secret and one-time
recovery code hashes are stored on the User; login becomes a two-step flow when 2FA is enabled.
A code is spent when it is accepted: User.totpLastStep holds the time step of the last one taken
and anything at or below it is refused, so a code caught in flight cannot be replayed while it is
still on the reader’s screen.
The guards, and the order they run in
Section titled “The guards, and the order they run in”A global AppThrottlerGuard runs first, then a global JwtAuthGuard protects every route by
default and routes opt out with a @Public() decorator. The rate limit comes in three tiers, the two tighter ones
overriding the global budget on the authentication routes and the erasure of financial data:
| Tier | Budget | Over |
|---|---|---|
| Global | 300 a minute | every route, counted per route per IP |
| Credentials | 10 a minute | the eight routes that check a password, a TOTP code or a recovery code |
| Account requests | 10 a quarter of an hour | sign-up and the two ends of a password reset |
The global budget is generous on purpose, counted per route per IP rather than across the whole
API, suitable for self-hosting and easy to tighten if exposed publicly. Which IP that is behind a
reverse proxy is the operator’s declaration and not the header’s word: TRUSTED_PROXY_HOPS counts
the trusted proxies in front, and common/throttling.ts says which address of the
X-Forwarded-For chain it then reads and what it does when the chain is shorter than declared.
The session list under Settings then Security asks that same reading for the address it records,
because a visitor who could choose it would be able to sign in wearing the owner’s own address on
the screen built to notice.
Encrypted at rest
Section titled “Encrypted at rest”Two things are encrypted at rest with AES-256-GCM, keyed by SECRET_ENCRYPTION_KEY: the TOTP
secret of an account with 2FA on, and the provider API keys a reader pastes in the settings
screen. A crypto wallet address is not one of them; it is public by construction and is stored in
the clear.
Administration and the demonstration account
Section titled “Administration and the demonstration account”A user has a role. AdminGuard gates /api/admin on role === 'admin', and on boot the
admin module promotes the oldest non-demo account if no admin exists, so an existing single-user
install does not lock its owner out. The admin surface lists users with their activity (accounts,
transactions, live sessions, API keys, 2FA state) and toggles registration through AppSetting,
which takes precedence over REGISTRATION_ENABLED.
The demonstration account is a normal user flagged isDemo, seeded on boot under
DEMO_SEED_ENABLED with a synthetic multi-asset portfolio, and re-anchored to today at each start.
Production ignores that variable: the account exists in the online demo,
whose server runs in the visitor’s tab, and on the stacks the e2e suite and the tools/ sweeps
run against. POST /auth/demo signs it in where it is seeded and is refused everywhere else. The
screenshots in the README come from that account.
The data model
Section titled “The data model”The Prisma schema (apps/api/prisma/schema.prisma) centers on the User, which owns every other
entity through cascading relations.
- User: credentials (
passwordHash),role(useroradmin), optional first and last name, theinvestorProfilequestionnaire (JSON), the displaypreferencesthat follow the account rather than the browser (JSON), theisDemoflag for the demonstration account, the encryptedtotpSecretwithtotpEnabled,recoveryCodeshashes and thetotpLastStepwatermark that makes a code single-use, and the single-useresetTokenHashwith its expiry for password reset. - RefreshToken: hashed rotating refresh tokens for sessions, with optional
userAgentandip. - ApiKey: scoped, revocable keys for the
/v1surface, mostly read scopes but also the two write ones the MCP tools need. Only the SHA-256 hash and a display prefix are stored, alongsidescopes,expiresAt,lastUsedAtandrevokedAt. - Account: a financial account with a
type(checking, savings, cto, pea, av, crypto_wallet, crowdlending, real_estate, loan, object, other), asyncMode(manual or connector), aconnectorMetacarrying the chain and address of a wallet in the clear,openedAt(the date the count to a PEA’s or an assurance-vie’s tax maturity runs from, the thresholds themselves beingtax.ts’s, below),archivedAt(closed accounts stay in history) and an optional account-levelcostBasis. - Asset: a security or coin (
kindof etf, fund, stock or crypto) with optionalisin,tickerandcoingeckoId, the fund ongoing charge (ter) and a lazily cachedcomposition(sector weightings and top holdings) with its fetch timestamp. - Holding: a position of an
Assetinside anAccount, withquantity,costBasisand cumulativefeesTotal. - Trade: a dated buy or sell lot (
side,quantity,unitPrice,amount,fee), which drives per-line lot history and real quantity over time. It keepsisinandsecurityNameso a fully sold line retains its identity after itsAssetlink is gone. - ManualBalance: dated balance points for simple accounts (checking, savings), unique per account and date.
- Transaction: a signed cash-flow line (negative means money out) with
label, optionaltype,fee,tax, acategoryIdand adedupHashthat is unique per account (the deduplication key). - DeletedTransaction: the gravestone of a line the reader deleted, one per
dedupHashand per account. It is what keeps a later import from writing the line back; the row it names is gone, so it is a copy of the fingerprint rather than a foreign key. - Category and CategoryRule: a per-user category tree, plus user-defined substring rules
(matched accent-insensitive and case-insensitive) that run before the generic keyword ruleset.
A category seeded from the default tree carries a
seedKey, unique per reader, which is what the keyword ruleset and the non-expense exclusion name: the name is the reader’s and they may rename it, so naming a category by its label made a rename produce a duplicate. The key is derived rather than declared (adoptSeedKeysworks it out from a row’s own name and from the shape of its subcategories), so a category the reader built themselves gets one when its contents show which branch of the tree it belongs to, and a null key means nothing could identify the row rather than that the reader created it. - PriceHistory: dated prices per asset and currency, unique per asset, date and currency.
- NetWorthSnapshot: a daily point of gross, liabilities and net worth with a JSON
breakdown, unique per user and date; it powers the evolution charts. - Goal: a named envelope the reader is saving toward, with a
targetAmountand the accounts that fund it inaccountIds. The safety cushion is one of these,kindofcushionand at most one per reader, whose target is the product ofmonthlyExpensesandmonthsCoveredrather than a figure of its own. An account funds at most one goal: linking it elsewhere detaches it, which is what keeps a euro from being counted under two targets.GoalsServiceenforces that on every write. - Credential: a per-user provider key stored as
ciphertext,ivandauthTag. The settings screen offers one, Moralis, read back on a wallet sync. It offered CoinGecko and OpenFIGI too, and neither row was ever opened: the keys those two APIs actually receive come fromCOINGECKO_DEMO_KEYandOPENFIGI_API_KEYin the environment, so the fields promised a rate limit they could not raise.CREDENTIAL_PROVIDERSnow refuses both names, whilelistandremovestill take any name, so a row an older build stored is shown and can be deleted rather than dropped underneath its owner. A wallet address is not a credential and is not stored here: it sits in the clear onAccount.connectorMeta. - CourseProgress and UserAchievement: what the reader has read and answered in the course,
and the dated unlocks of the progress tiers. A criterion derivable from the reader’s own figures is
re-evaluated on every read, so
UserAchievementnever becomes the source of truth for one: it dates the unlock, remembers whether the celebration was shown, and holds the facts only the browser knows (a theme picked, a simulator opened). - AppSetting: a tiny key/value table for instance-wide settings an admin can change at runtime (currently whether registration is open), which is why each setting overrides its env default.
The figures, and where each one is computed
Section titled “The figures, and where each one is computed”Every number the product shows comes out of one of the services below, and each of them says what it assumes and what it refuses to guess.
Valuation, snapshots and backfill
Section titled “Valuation, snapshots and backfill”The valuation layer computes the EUR value of each account (holdings priced from PriceHistory,
manual balances taken as-is), then aggregates net worth (gross minus liabilities such as loans)
and allocation by asset, type or class, reusing the Decimal helpers from packages/shared.
A snapshots service writes a daily NetWorthSnapshot (a scheduled cron job, a run after each
mutation and an on-demand endpoint). Because real snapshots cannot be created retroactively, a
backfill step reconstructs history: it fetches historical prices (CoinGecko for crypto, Yahoo for
ETFs) and rebuilds past snapshots by valuing positions at historical prices, using the real dated
quantities from Trade where available. Backfill never overwrites real snapshots.
Every rebuilt point therefore records how many of its lines it had to assume
(breakdown.quantities), the same way a live point records the price freshness it rested on
(breakdown.pricing). snapshot-pricing.ts reads both back: seriesGapOf names the drawn points
taken while a price was missing, seriesAssumptionOf the drawn points that were never really
held, and every reader of the history states its answer on both axes (guarded in
snapshot-history.test.ts). A history made of dated manual balances is exact and carries no
caveat.
One currency, and what that costs
Section titled “One currency, and what that costs”Nothing here converts money: there is no exchange-rate source. VALUATION_CURRENCY in
common/valuation-currency.ts names the one currency every total is expressed in, and every
reader of PriceHistory filters on it, so a close in dollars can never be summed into a euro
total. Quotes are stored in the currency the source stated, and Asset.currency records that
currency the first time a quote arrives, which is the only place it is known.
The consequence is that a security quoted elsewhere is a line no total can value, for good. It is
counted in unpricedHoldings like any other missing line, and the currency is named next to it:
each account and each pricing block carries foreignCurrency, each position row carries
quoteCurrency, and a refresh reports such a line under foreignCurrency rather than as
refreshed. An account declared in another currency is refused at creation, because its balances
would otherwise be added to the total one for one. price-currency.test.ts requires a written
decision from every file that touches a price row, and forbids filtering on a hard-coded currency
string.
Real estate and loans
Section titled “Real estate and loans”Real estate and loans are ordinary accounts with a dedicated sheet: real-estate stores the
property details and the loan terms, computes the amortization schedule (principal versus
interest, payoff date, equity built) and feeds valuation. Loans are liabilities, so net worth is
gross minus their balance.
The fees module aggregates what has actually been paid (per-line fees, plus fee and tax on
transactions) and combines it with each fund’s ongoing charge to project the drag forward. Every
figure of euros paid is the invested book’s, apps/api/src/fees/invested-book.ts: the lines of a
securities account that are a fund, an ETF or a stock, the fees and taxes of those accounts’
operations, and the brokerage of their dated orders. Both halves of that scope are needed, since
the account category alone lets a coin bought inside a CTO back into a total it brings no value
to. Ongoing charges are fetched from the price source when it publishes one, and are otherwise
entered by hand; they are informational, since a TER is already reflected in the fund’s price.
What the platform itself takes (custody fees, account-keeping charges) is on a statement and on no
operation, so the reader declares a yearly fee per account: Account.platformFeeAnnual in euros
or Account.platformFeeAnnualPct as a percentage of the account’s value, never both, null until
they type one. The declared platform fees are the one block that still lists every account. No
rate card of any bank or broker is shipped, in a default or in a placeholder, because they differ
per platform and get renegotiated. The annual fee enters the twenty-year projection, a percentage
at the account’s value today as ValuationService gives it. There is no per-operation fee: the
commission actually paid is on each order. The annual fee does not enter what has been paid: a
declared tariff is not a euro on file. An account nothing is declared for is counted as unanswered
and said to be, so a total the module cannot complete never reads as complete.
The tax treatment of a gain
Section titled “The tax treatment of a gain”The French tax treatment of a gain is declared once, in packages/shared/src/tax.ts: the social
levies, the income tax owed before an envelope’s threshold, the income tax left once it is behind,
and the two thresholds themselves. It was written in thirteen places until 6 September 2026, eight
of them executable, and two of them already disagreed about a mature assurance-vie. Every reader of
the figures now goes through that table: the estimate on an account’s own tab, the advice engine’s
maturity card, and the wealth simulator’s starting rates. One place is deliberately left literal:
the teaching material, course.json and learn.json in both locales, whose chapters explain what
a rate is and how it came to be what it is rather than computing anything. Part of those files is
under test all the same: the two glossary entries that recite the figures with no history around
them are pinned against the table by apps/web/src/i18n/taxRates.test.ts, so moving a rate without
moving them fails a test, while the chapter bodies are still edited by hand. The docblock at
the head of tax.ts argues that exception and is the note to read before moving a figure.
Those rates are a default that each account can override. Three nullable columns on the account
(taxSocialPct, taxIncomePct, taxMaturedIncomePct) override them one at a time, null meaning
“the ordinary case” and never “nothing owed”, and the card that draws the estimate says which rates
it applied. What is deliberately not modelled is the rest of the regime: the date of each premium
(the 2017 reform turns on 27 September 2017 and on the payment, not on the contract), the household
the annual allowance depends on, and the total paid across every contract the reader holds
anywhere. None of the three is a fact this database holds, so the estimate guesses none of them.
Recommendations and learning content
Section titled “Recommendations and learning content”The recommendations module derives advice cards from the user’s own aggregates (emergency fund, concentration, cash share, fee level and so on). Nothing is fetched, nothing is stored: the rules run on read.
The learning area (course, concepts and their vocabulary, investment pyramid) is entirely
client-side content living in the course and learn i18n namespaces, excluded from the eager
locale glob and registered from the lazily loaded Learn chunk so the login path stays small.
Providers, import and the public API
Section titled “Providers, import and the public API”Everything that crosses the boundary: the sources a price comes from, the one route a batch of operations arrives by, and the scoped surface a program drives.
Integrations
Section titled “Integrations”External providers are keyless where possible, with optional keys to raise rate limits.
| Fetched | Source | Key |
|---|---|---|
| BTC balances | mempool.space, blockstream.info | none |
| ETH native balance | a public RPC | none |
| ERC-20 tokens | Moralis | the reader’s own |
| ETF prices and history | OpenFIGI, then Yahoo chart data | optional OPENFIGI_API_KEY |
| Fund composition | Yahoo quoteSummary |
none |
| Crypto prices, recent history | CoinGecko, in EUR | optional COINGECKO_DEMO_KEY |
| Crypto history past 365 days | Binance daily EUR klines | none |
A BTC balance is its funded outputs minus its spent ones. OpenFIGI resolves an ISIN to a Yahoo
symbol, adding the exchange suffix, for example .PA for Euronext Paris, and Yahoo answers the
quote and the history. The Moralis key is the one the reader pastes in the settings screen; the
other two come from the environment and nowhere else.
CoinGecko’s free tier answers 365 days, which is why the backfill walks Binance for the stretch before that. Binance is never asked for today’s price and is never the only source: when it does not answer, the backfill falls back to CoinGecko’s year and names it as unavailable rather than returning a shorter curve without a word.
The import pipeline
Section titled “The import pipeline”Nothing in the API reads a file. Import takes canonical transactions the caller has already
extracted, and the only surface that offers it is the scoped /api/v1 below: POST /v1/import/reconcile is a dry run that writes nothing and reports what a batch would do, POST /v1/import writes it. Both validate the payload against the canonical schema (zod) from
packages/shared and flag the lines the account already holds; reconcile also names relabeled
near-duplicates and the questions a caller should put to the reader first. On commit, rows are
inserted with createMany and skipDuplicates against the unique (accountId, dedupHash)
constraint, so sending the same batch twice is safe, and a line the reader deleted after an
earlier import is held back by its gravestone rather than written again.
A line is named by batchDedupKeys (packages/shared/src/canonical.ts): its date, amount and
label, plus its rank among the identical lines of the same batch. The rank is what lets a
statement carrying the same operation twice on the same day keep both lines, which is an ordinary
thing for a statement to do; it lives in the fingerprint and never in the label, because the label
is the reader’s ledger. The dry run and the commit call that one function on the same batch, so a
caller that reviews a batch before writing it reads back exactly what will be written. Callers do
not get to supply a fingerprint of their own.
Nothing here parses a CSV or extracts anything from a statement: the reading is the MCP client’s
job. Your MCP client parses the statements under whatever model you already pay for or run, and
calls the two routes above, so no key for it is configured in Badlen, and ImportService injects
nothing but Prisma.
The /v1 API
Section titled “The /v1 API”A scoped public surface lives under /api/v1 (apps/api/src/api-keys). Requests authenticate
with an ApiKey bearer token instead of the session cookie: an ApiKeyGuard verifies the key,
checks the required @Scopes and bypasses the JWT guard.
| Scope | Opens |
|---|---|
dashboard:read |
net worth and the dashboard |
accounts:read |
accounts |
insights:read |
insights |
transactions:read |
transactions |
prices:read |
prices |
positions:read |
positions |
analytics:read |
analytics |
accounts:write |
creating an account, setting a balance, adding a holding |
transactions:write |
importing transactions, and the dry run that precedes it |
The two write scopes are what the MCP server in packages/mcp calls. Keys are created in the
settings UI, shown once, stored only as a SHA-256 hash, and can carry an expiry and be revoked.
The surface itself is written down route by route in this site’s REST API
reference and its French twin: the verb, the path, the scope a
key must carry, the body accepted and the shape answered. Both are generated from the controller
by pnpm api:reference and regenerated by the API suite on every pnpm test, so they are the one
description of these routes that cannot fall behind them.
Every total this surface returns carries a pricing block saying how many lines it rests on and
how old the oldest price is, so a caller can tell an outage from a portfolio that shrank.
Amounts on the wire
Section titled “Amounts on the wire”Send every amount and every quantity as a decimal string, with a dot separator and no thousands separator:
{ "amount": "1234.56", "quantity": "0.123456789012345678" } accepted{ "amount": 1234.56 } 400A string is stored in its Decimal column exactly as written.
The digit counts the patterns hold a field to are the columns’ own (two decimals for money,
eighteen for a held quantity, four for a rate), and they live in
apps/api/src/common/decimal-input.ts, one shape per column shape.
Running it, and changing it
Section titled “Running it, and changing it”This page stops at the code. The rest of this site covers running what is described above:
Configuration is the compose file and the environment it reads, Updating an instance
moves to a new image without losing the volume, and Taking backups
is the JSON export, the pg_dump copy and what neither of them carries.
.github/CONTRIBUTING.md
is for changing it: the local setup, the quality gate, and the migration rules that keep the schema
above from drifting from the database.