Finper is a self-hosted personal finance management application organized as a pnpm monorepo. A React 19 single-page application communicates with an Express 5 REST API over HTTP. The API owns all business logic and persists data to a local SQLite file via Drizzle ORM. Shared TypeScript types and the database schema each live in their own workspace package so both the frontend and backend stay in sync without duplication.Documentation Index
Fetch the complete documentation index at: https://mintlify.com/soker90/finper/llms.txt
Use this file to discover all available pages before exploring further.
Monorepo structure
Package dependency diagram
api depends on db and types via workspace:* and consumes their compiled build output. The API will not start if those packages have not been built first. client consumes the shared interfaces from @soker90/finper-types directly at build time.
Request lifecycle
Browser axios call
The React client calls
axios.get('accounts') with baseURL set to VITE_API_HOST. The axios interceptor (configured in authService.setSession) automatically attaches the stored JWT as the Authorization: Bearer <token> request header before the call leaves the browser.Express :3008 receives the request
The Express server on port 3008 accepts the incoming HTTP request.
preMiddlewareConfig
The request passes through
preMiddlewareConfig: helmet, express.json, express.urlencoded, compression, and cors middleware all run in sequence.Route matching
Express matches the path (e.g.
/api/accounts) and hands the request to the corresponding router file — for example accounts.routes.ts.authMiddleware — passport JWT
authMiddleware calls passport.authenticate('jwt', cb). On success it sets req.user = username (a plain string) and writes a refreshed token to the Token response header.Controller Promise chain
The controller executes a Bluebird Promise chain:
.tap(log) → .then(extractUser) → .then(validateXxxParams) → .tap(validateXxxExist) → .then(service.method) → .then(res.send) | .catch(next)Service layer
The service applies business logic and throws
Boom.<x>(...).output for any domain errors.Drizzle ORM → SQLite
The service calls
@soker90/finper-db which executes the Drizzle query against the SQLite file.Build order
| Order | Command | Why |
|---|---|---|
| 1 | pnpm install | Resolves the workspace graph and writes the lockfile. |
| 2 | make build-types and make build-db | Produces the compiled dist/ artifacts that the API imports at runtime. |
| 3 | make start-api | Starts Express on :3008. Requires the builds from step 2. |
| 4 | make start-client | Starts Vite on :5173. Independent of steps 2 and 3 at the build level. |
Key architectural decisions
Why SQLite?
Why SQLite?
Finper is a self-hosted app designed to run on a single machine without any external infrastructure. SQLite requires no separate server process, stores all data in a single file, and is trivially portable. Drizzle ORM provides a type-safe query builder on top of the
better-sqlite3 synchronous driver, and migrations are applied automatically when the API starts via migrate() in server.ts.Why a monorepo with separate db and types packages?
Why a monorepo with separate db and types packages?
Splitting
@soker90/finper-db (schema + connection) and @soker90/finper-types (TypeScript interfaces) into their own packages lets both the API and the React client share the same type definitions without coupling the frontend to Drizzle ORM or better-sqlite3. The cost is that both packages must be built before the API can start, and any schema change requires a rebuild of db before restarting the API.Why Bluebird as global.Promise?
Why Bluebird as global.Promise?
server.ts replaces global.Promise with Bluebird at startup. This gives every controller access to .tap(), which is used to fire logging side-effects and existence validators inside the Promise chain without breaking the data flow. The trade-off is an extra dependency and slightly different rejection semantics compared to native Promises.Manual dependency injection — no container
Manual dependency injection — no container
Services are instantiated as module-level singletons and passed to controller factory functions (e.g.
createUsersController(usersService)). There is no DI container. This keeps the codebase simple and makes unit testing straightforward: tests import the factory and pass a mock service directly.SWR for reads, plain functions for writes in the frontend
SWR for reads, plain functions for writes in the frontend
The client uses SWR for all data-fetching operations. SWR provides automatic caching, background revalidation, and deduplication. For mutations (POST / PUT / DELETE), the client calls plain
axios functions and then calls mutate() to trigger SWR revalidation. This pattern avoids duplicated loading states while keeping write paths simple and explicit.