Track your money across LCL accounts, assets, debts, and goals, self-hosted and behind your own SSO.
This relies on an unofficial, reverse-engineered LCL API and deals with real banking credentials. It's built for personal use against my own account, so use it at your own risk, and never commit or share captured identifiers, keypads, or session tokens. This is also a personal project, not a polished product: expect missing pieces, sharp edges, and breaking changes.
- π β’ Overview
- π§© β’ Components
- π οΈ β’ Tech Stack
- π β’ Getting Started
- π§ͺ β’ Demo Mode
- β€οΈ β’ Health Check
- π β’ Project Structure
- π³ β’ Docker Deployment
- π¦ β’ Releases
- π€ β’ Contributing
- π β’ Credits
- π β’ License
Prisme is a personal money-tracking app built around LCL, a French bank with no official public API. It reverse-engineers LCL's private web API (monespace.lcl.fr/api) to pull account balances and transactions into a PostgreSQL database you control, then displays everything in a dashboard behind your own SSO.
Key features:
- Sync current and savings accounts, balances, and transactions straight from LCL
- Hierarchical, user-defined transaction categories, with local (non-cloud) ML category suggestions
- Net worth tracking: manually-tracked assets, debts, cash on hand, and Cheques-Vacances
- Savings goals (fixed target or recurring) and per-category monthly budgets
- Income forecasting from your own categorized salary history
- Shared/joint account support: multiple Prisme users can sync the same LCL account
- Authentication via Authentik (OIDC SSO); no separate Prisme accounts/passwords
- Per-device session duration (15 minutes to 30 days of inactivity), with a list of signed-in devices you can sign out remotely from Account β Security
- Installable as a PWA ("Add to Home Screen" / "Install app") for quick access on mobile
The project is split into three pieces under src/, meant to run as a pipeline:
flowchart TD
LCL[("LCL<br/>monespace.lcl.fr")]
You(("You"))
Ext["Extension<br/>captures session & encrypts"]
FE["Frontend (Next.js)<br/>behind Authentik SSO"]
Worker["Worker (Python)<br/>sync Β· categorize Β· forecast"]
DB[("PostgreSQL")]
You -- "logs in" --> LCL
LCL -. "webRequest captures<br/>login + account data" .-> Ext
Ext -- "encrypted payload<br/>(manual copy-paste,<br/>passphrase from frontend)" --> FE
FE -- "stores encrypted credentials,<br/>enqueues sync" --> DB
Worker -- "polls queue,<br/>decrypts credentials" --> DB
Worker -- "fetches accounts &<br/>transactions" --> LCL
Worker -- "persists data,<br/>categorizes, forecasts" --> DB
FE -- "reads & displays" --> DB
You -- "views dashboard" --> FE
| Component | Status | Description |
|---|---|---|
src/extension |
Working | A browser extension you run once while logging into monespace.lcl.fr. It captures the session credentials from that login, encrypts them, and lets you hand them off to the frontend. |
src/worker |
Working | A Python service that decrypts those credentials, calls LCL's API (balances, transactions, savings accounts), persists everything to PostgreSQL, predicts transaction categories, and forecasts income. |
src/frontend |
Working | A Next.js dashboard behind Authentik SSO: accounts, transactions, budgets, goals, assets/debts, and the onboarding flow that pairs a user with their LCL credentials. |
| Layer | Technology |
|---|---|
| Frontend | Next.js 16 (App Router, React 19), TypeScript 5 |
| Styling / UI | Tailwind CSS v4, shadcn/ui, Radix / base-ui |
| Charts | Recharts |
| Auth | NextAuth.js + Authentik (OIDC SSO) |
| Worker | Python 3.13, managed with uv |
| LCL client | aiohttp (reverse-engineered LCL API, lib/LCLPy) |
| Categorization | scikit-learn (local TF-IDF + logistic regression, per user) |
| Database | PostgreSQL 17 (pgcrypto for credential encryption, pg_trgm for search) |
| Extension | WebExtension Manifest V3, bundled with webpack |
| Containerization | Docker Compose |
- Node.js β₯ 20 and npm (frontend + extension)
- Python β₯ 3.13 and uv (worker)
- Docker & Docker Compose (PostgreSQL, and optionally the worker)
- A browser (Chrome or Firefox) to load the extension
- An Authentik instance (or another OIDC provider, if you adapt
src/frontend/lib/auth.ts) for SSO
# 1. Clone the repository
git clone https://github.com/PaulBayfield/Prisme.git
cd Prisme
# 2. Install the extension's dependencies
cd src/extension && npm install && cd ../..
# 3. Install the worker's dependencies
cd src/worker && uv sync && cd ../..
# 4. Install the frontend's dependencies
cd src/frontend && npm install && cd ../..How Prisme's .env files are scoped depends on whether you run it via Docker or locally:
Docker deployment - a single .env.example β .env at the repo root, passed to all three services (prisme-db, prisme-worker, prisme-frontend) by docker-compose.yml:
| Variable | Required | Description |
|---|---|---|
POSTGRES_DATABASE |
Yes | Database name |
POSTGRES_USER |
Yes | Database user |
POSTGRES_PASSWORD |
Yes | Database password |
CREDENTIALS_ENCRYPTION_KEY |
Yes (unless DEMO_MODE=true) |
pgcrypto passphrase used to decrypt stored LCL credentials, shared by the worker and the frontend |
DEMO_MODE |
No (default false) |
When true, runs the frontend against in-memory demo fixtures instead of PostgreSQL/Authentik - see Demo Mode. The AUTHENTIK_* variables below are ignored in this mode |
NEXTAUTH_URL |
Yes | Public URL of the frontend |
NEXTAUTH_SECRET |
Yes | Run openssl rand -base64 32 |
AUTHENTIK_ISSUER |
Yes (unless DEMO_MODE=true) |
Your Authentik issuer URL |
AUTHENTIK_CLIENT_ID |
Yes (unless DEMO_MODE=true) |
OIDC client id (Authentik admin β Applications) |
AUTHENTIK_CLIENT_SECRET |
Yes (unless DEMO_MODE=true) |
OIDC client secret |
AUTHENTIK_USER_INFO_URL |
Yes (unless DEMO_MODE=true) |
Authentik userinfo endpoint |
POSTGRES_HOST/POSTGRES_PORT aren't in this file - docker-compose.yml hardcodes them to prisme-db/5432 for the worker and frontend services, since that's how they reach PostgreSQL over the compose network regardless of what's in .env. NEXT_PUBLIC_APP_VERSION isn't here either - it's baked into the published ghcr.io/paulbayfield/prisme-frontend image at CI build time instead, from package.json - see Docker Deployment.
Local (non-Docker) development - two independent scopes, each with its own .env.example to copy from. This split isn't optional: npm run dev and uv run python __main__.py each load their own scoped file by convention (Next.js auto-loads .env.local from src/frontend/; the worker's load_dotenv(dotenv_path=".env") resolves relative to src/worker/), so the root .env above has no effect on either when run outside Docker.
1. src/worker/.env.example β src/worker/.env, read by the worker and its scripts:
| Variable | Required | Description |
|---|---|---|
POSTGRES_HOST |
No (default localhost) |
PostgreSQL host |
POSTGRES_PORT |
No (default 5432) |
PostgreSQL port |
POSTGRES_DATABASE |
Yes | Database name |
POSTGRES_USER |
Yes | Database user |
POSTGRES_PASSWORD |
Yes | Database password |
CREDENTIALS_ENCRYPTION_KEY |
Yes | pgcrypto passphrase used to decrypt stored LCL credentials, must match the frontend's value |
2. src/frontend/.env.example β src/frontend/.env.local:
| Variable | Required | Description |
|---|---|---|
DEMO_MODE |
No (default false) |
When true, runs the frontend against in-memory demo fixtures instead of PostgreSQL/Authentik - see Demo Mode. All variables below are ignored in this mode |
POSTGRES_* |
Yes (unless DEMO_MODE=true) |
Same PostgreSQL connection info as above |
NEXTAUTH_URL |
Yes | Public URL of the frontend |
NEXTAUTH_SECRET |
Yes | Run openssl rand -base64 32 |
AUTHENTIK_ISSUER |
Yes (unless DEMO_MODE=true) |
Your Authentik issuer URL |
AUTHENTIK_CLIENT_ID |
Yes (unless DEMO_MODE=true) |
OIDC client id (Authentik admin β Applications) |
AUTHENTIK_CLIENT_SECRET |
Yes (unless DEMO_MODE=true) |
OIDC client secret |
AUTHENTIK_USER_INFO_URL |
Yes (unless DEMO_MODE=true) |
Authentik userinfo endpoint |
CREDENTIALS_ENCRYPTION_KEY |
Yes (unless DEMO_MODE=true) |
Must be the exact same value as in src/worker/.env |
NEXT_PUBLIC_APP_VERSION |
No | Version shown in the help dialog's badge when running npm run dev/npm run build directly |
# 1. Start PostgreSQL
docker compose up -d prisme-db
# 2. Run the worker (polls the sync queue, syncs every connected user hourly)
cd src/worker
uv run python __main__.py
# 3. Run the frontend
cd src/frontend
npm run devThe frontend is available at http://localhost:3000. After onboarding, go to Settings β Compte to connect an LCL account: generate a passphrase there, paste it into the extension popup, then paste the encrypted result back.
To build and load the extension:
cd src/extension
npm install
npm run dev # one-off development build into dist/Then load src/extension/dist as an unpacked extension: chrome://extensions β Developer mode β Load unpacked (Chrome), or about:debugging β Load Temporary Add-on (Firefox).
Set DEMO_MODE=true in src/frontend/.env.local to run the frontend against in-memory fixtures (lib/demo) instead of PostgreSQL and Authentik. This is what powers the live demo: the sign-in page shows a single "Essayer la dΓ©mo" button (components/demo-auth-form.tsx) that authenticates into a fixed demo identity, no real LCL account, database, or Authentik instance required.
cd src/frontend
echo "DEMO_MODE=true" >> .env.local
npm run devIn this mode:
POSTGRES_*,AUTHENTIK_*, andCREDENTIALS_ENCRYPTION_KEYare not read and can be left unsetNEXTAUTH_URLandNEXTAUTH_SECRETare still required - NextAuth needs them to sign/verify the session JWT regardless of which provider (Authentik or the demo credentials provider) is active- All reads/writes (
lib/data.ts,lib/actions.ts) are routed to fixture data instead of the real Postgres-backed implementations, so nothing is persisted between restarts - No data ever leaves the fixtures - the extension, worker, and LCL are never involved
The frontend exposes an unauthenticated health check at /health:
curl http://localhost:3000/health{
"status": "ok",
"uptime": "42s",
"timestamp": "2026-07-04T12:00:00.000Z",
"unix": 1783598400
}Useful for uptime monitors, container health checks, or a reverse proxy readiness probe.
Prisme/
βββ .github/
β βββ dependabot.yml
β βββ workflows/
β βββ extension-release-please.yml # Builds, zips & releases the extension
βββ docker-compose.yml # PostgreSQL + worker
βββ src/
β βββ extension/ # WebExtension (MV3) - captures LCL credentials
β β βββ src/
β β β βββ background.js # webRequest listener, captures login/account data
β β β βββ popup.js # encrypts captured data for export
β β β βββ main.js # content script injected on monespace.lcl.fr
β β βββ popup.html
β β βββ manifest.json
β β βββ webpack/
β βββ worker/ # Python service - syncs LCL data into PostgreSQL
β β βββ lib/LCLPy/ # Reverse-engineered LCL API client
β β βββ script/ # decrypt.py, seed_credentials.py, import_legacy_transactions.py
β β βββ tests/test_client.py # manual smoke test (not run in CI)
β β βββ worker.py # syncs one user's accounts/balances/transactions
β β βββ categorizer.py # local ML category prediction
β β βββ income_forecast.py
β β βββ schema.sql
β β βββ __main__.py # sync queue daemon entry point
β βββ frontend/ # Next.js dashboard behind Authentik SSO
β βββ app/(app)/ # App Router pages (accounts, budgets, goals, ...)
β β βββ health/route.tsx # GET /health - status, uptime, timestamp
β βββ components/ # UI components (shadcn/ui based)
β βββ lib/ # data.ts / actions.ts / auth.ts / db.ts
β β βββ demo/ # In-memory fixtures used when DEMO_MODE=true
β βββ app/manifest.ts # PWA web app manifest
β βββ proxy.tsx # NextAuth middleware, excludes /health from auth
βββ LICENSE
docker compose up -dThis starts:
prisme-db: PostgreSQL 17, initialized fromsrc/worker/schema.sqlprisme-worker: pullsghcr.io/paulbayfield/prisme-worker, reads the root.env, polls the sync queue every 10 seconds, and enqueues a full sync per connected user every hourprisme-frontend: pullsghcr.io/paulbayfield/prisme-frontend, reads the root.envat runtime, serves on port 3000
Both images are built and published to ghcr.io on every push to main (see worker-ci.yml/frontend-ci.yml), tagged latest and by commit SHA. To pick up a new release:
docker compose pull prisme-worker prisme-frontend
docker compose up -dIf you'd rather build the images locally instead of pulling from ghcr.io (e.g. to test an unmerged change), swap the image: line for the corresponding service in docker-compose.yml for a build: block (context: ./src/worker or ./src/frontend).
The extension has its own release-please workflow (extension-release-please.yml, scoped to src/extension/**): on merge to main, it builds the production bundle, zips it, renames it to .xpi, attaches it to a GitHub release, and updates .ff_updates.json (the Firefox self-hosted update manifest). The worker and frontend are built and published as container images on every push to main - see Docker Deployment.
This is a personal, single-maintainer project (see Disclaimer), so there's no formal CONTRIBUTING.md or process. Issues and pull requests are still welcome if something's broken or you have a suggestion.
| Person | Role |
|---|---|
| Paul Bayfield | Creator & maintainer |
Prisme also relies on a few external services:
| Service | Used for |
|---|---|
| LCL (unofficial, reverse-engineered API) | Syncing account balances and transactions (src/worker/lib/LCLPy) |
| Authentik | OIDC single sign-on |
| Frankfurter (European Central Bank rates) | Live exchange rates for the currency converter (src/frontend/lib/exchange-rate.ts) |
Prisme is licensed under the AGPL 3.0.
Prisme
Copyright (C) 2026 Paul Bayfield
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
