Skip to content

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 migrations
apps/web React web app
packages/shared domain types, the canonical import schema, Decimal money,
valuation and performance helpers
packages/mcp the MCP server an MCP client drives
docs this documentation site

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

One module per domain, provider SDKs kept behind adapters, and shared arithmetic in one package both ends call.

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.

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

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

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.

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.

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.

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 Prisma schema (apps/api/prisma/schema.prisma) centers on the User, which owns every other entity through cascading relations.

  • User: credentials (passwordHash), role (user or admin), optional first and last name, the investorProfile questionnaire (JSON), the display preferences that follow the account rather than the browser (JSON), the isDemo flag for the demonstration account, the encrypted totpSecret with totpEnabled, recoveryCodes hashes and the totpLastStep watermark that makes a code single-use, and the single-use resetTokenHash with its expiry for password reset.
  • RefreshToken: hashed rotating refresh tokens for sessions, with optional userAgent and ip.
  • ApiKey: scoped, revocable keys for the /v1 surface, mostly read scopes but also the two write ones the MCP tools need. Only the SHA-256 hash and a display prefix are stored, alongside scopes, expiresAt, lastUsedAt and revokedAt.
  • Account: a financial account with a type (checking, savings, cto, pea, av, crypto_wallet, crowdlending, real_estate, loan, object, other), a syncMode (manual or connector), a connectorMeta carrying 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 being tax.ts’s, below), archivedAt (closed accounts stay in history) and an optional account-level costBasis.
  • Asset: a security or coin (kind of etf, fund, stock or crypto) with optional isin, ticker and coingeckoId, the fund ongoing charge (ter) and a lazily cached composition (sector weightings and top holdings) with its fetch timestamp.
  • Holding: a position of an Asset inside an Account, with quantity, costBasis and cumulative feesTotal.
  • Trade: a dated buy or sell lot (side, quantity, unitPrice, amount, fee), which drives per-line lot history and real quantity over time. It keeps isin and securityName so a fully sold line retains its identity after its Asset link 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, optional type, fee, tax, a categoryId and a dedupHash that is unique per account (the deduplication key).
  • DeletedTransaction: the gravestone of a line the reader deleted, one per dedupHash and 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 (adoptSeedKeys works 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 targetAmount and the accounts that fund it in accountIds. The safety cushion is one of these, kind of cushion and at most one per reader, whose target is the product of monthlyExpenses and monthsCovered rather 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. GoalsService enforces that on every write.
  • Credential: a per-user provider key stored as ciphertext, iv and authTag. 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 from COINGECKO_DEMO_KEY and OPENFIGI_API_KEY in the environment, so the fields promised a rate limit they could not raise. CREDENTIAL_PROVIDERS now refuses both names, while list and remove still 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 on Account.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 UserAchievement never 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.

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.

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

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.

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.

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.

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.

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.

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

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

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.