Company spend management and finance operations platform.
Financy is the control and orchestration layer for company spending: policy is enforced before money is spent, evidence is captured as it is spent, and reconciliation becomes a review of an already-complete record rather than an archaeological dig.
It is deliberately not a bank, a card network, or a general ledger. It governs, records, and explains spend, and integrates with the institutions and accounting systems that actually move and book money.
docs/ is the source of truth. Start with docs/README.md, which explains the
hierarchy and the change-management order.
| If you want to know… | Read |
|---|---|
| What the product is and why | 01-PRODUCT-REQUIREMENTS.md |
| What is in the MVP | 02-PRODUCT-SCOPE.md |
| Who can do what | 03-USER-ROLES-PERMISSIONS.md |
| How it is built | 08-ARCHITECTURE.md |
| The data model | 09-DATABASE-DESIGN.md |
| The API contract | 10-API-SPECIFICATION.md |
| How spend gets approved | 11-APPROVAL-POLICY-ENGINE.md |
| The security model | 12-SECURITY-MODEL.md |
| When a feature is done | 19-DEFINITION-OF-DONE.md |
| Why a decision was made | 20-DECISIONS.md |
Prerequisites: Node ≥ 20.11, pnpm ≥ 9, PostgreSQL ≥ 16. Docker is optional — see Local infrastructure below.
pnpm install # 1 · install
cp .env.example .env # 2 · configure, then set DATABASE_URL
pnpm db:migrate # 3 · apply migrations
pnpm db:seed # 4 · seed system data + a demo organisation
pnpm dev # 5 · web :3100 · api :4100The database is MongoDB, temporarily; PostgreSQL remains the design. ADR-0017 in
docs/20-DECISIONS.md records why, exactly which guarantees that costs, and what has to be
restored when PostgreSQL returns. Read it before trusting docs/09-DATABASE-DESIGN.md, which
describes the schema as designed rather than as it currently runs.
Whatever DATABASE_URL points at must be a replica set. Every write path in this
application runs inside an interactive transaction, and MongoDB has no transactions on a
standalone server. A single node is enough:
# A single-node replica set, initiated for you on startup.
docker run -d -p 27017:27017 --name financy-mongo mongodb/mongodb-atlas-local# .env
DATABASE_URL=mongodb://localhost:27017/financy_dev?directConnection=trueThen create the collections and indexes:
pnpm db:push # no migration history under Mongo — see ADR-0017
pnpm db:seed:system # the permission catalogue, in every environment
pnpm db:seed:demo:full # a demo organisation, with accounts you can sign in asNever connect as the admin or root user, in any environment — create a user scoped to this
database with readWrite and nothing more. The config schema refuses a PostgreSQL superuser
outright, and the same rule applies here.
Redis and S3 are not required locally. The reference development host has neither Docker nor WSL (audit finding P3/P4), so the architecture provides adapters for exactly that case:
| Dependency | Local | Staging / production |
|---|---|---|
| Queue | InlineQueueAdapter — in-process, runs after commit |
BullMQ + Redis |
| Object storage | LocalDocumentProvider — filesystem with HMAC-signed expiring URLs |
S3 |
| Console outbox | SMTP / ESP | |
| Cards, payments, OCR | Mock adapters, labelled as sandbox everywhere | Real providers (Phase 7) |
If you do have Docker, infra/docker-compose.yml provides PostgreSQL, Redis, MinIO, and
Mailpit. It is what CI's stack mirrors. It is never the only supported path.
docker compose -f infra/docker-compose.yml up -dIts PostgreSQL publishes 5442, not 5432, because 5432 is taken on the reference host and a
container that silently fails to bind is a worse problem than an unfamiliar port. It provisions
the financy_app role, both databases, and the required extensions on first start, so
DATABASE_URL becomes:
postgresql://financy_app:financy_app@localhost:5442/financy_dev
3000 and 5433 are occupied on the reference host, so:
| Service | Port |
|---|---|
| Web | 3100 |
| API | 4100 |
All ports are environment-driven.
backend/ NestJS modular monolith — HTTP and worker entrypoints, one artefact
frontend/ Next.js 15 App Router
packages/
core/ Money, Result, errors, ids, state machines — zero I/O, zero framework
contracts/ Zod schemas + inferred types — the shared API contract
db/ Prisma schema, migrations, seed, tenant client extension
ui/ Design system
config/ tsconfig / eslint / vitest presets
docs/ Source of truth, incl. diagrams/
infra/ docker-compose, Dockerfiles
scripts/ Cross-platform maintenance scripts
tests/ Cross-cutting e2e and security suites
packages/contracts is the joint that keeps the two applications honest: one Zod schema,
validated on the server, inferred as types on the client. A contract change that breaks either
side fails the build.
Start here:
npm start # frontend + backend, ports freed firststart is the one command that should always work. It stops whatever is holding 3100 and
4100 — including the watchers behind them, which is what actually causes the EADDRINUSE
you get an hour later — and then runs both applications in watch mode.
Each half starts the same way from its own directory, freeing only its own port so the other one keeps running:
cd frontend && npm start # http://127.0.0.1:3100
cd backend && npm start # http://127.0.0.1:4100npm start runs a watcher, which is what you want while developing. To run the built
artefact the way production does, use npm run start:prod after pnpm build.
| Command | Does |
|---|---|
npm start |
Free the ports, then run everything in watch mode |
pnpm dev |
Run everything in watch mode, without freeing ports |
pnpm build |
Build all packages |
pnpm check |
Lint + typecheck + test — run before pushing |
pnpm test |
Unit, integration, and API tests |
pnpm test:coverage |
Tests with coverage thresholds enforced |
pnpm test:e2e |
Playwright — starts the whole stack itself |
pnpm lint / pnpm lint:fix |
ESLint |
pnpm format |
Prettier |
pnpm db:migrate |
Apply migrations (development) |
pnpm db:studio |
Prisma Studio |
pnpm diagrams |
Regenerate docs/diagrams/*.mmd |
pnpm ports |
What is holding 3100 / 4100 (--free stops it) |
pnpm clean |
Remove build output |
These are enforced by lint rules, tests, and database constraints — not by convention. Full list
in 19-DEFINITION-OF-DONE.md.
- Money is never a float.
NUMERIC(20,4)in the database,Moneyin the domain, a string with an explicit currency on the wire.JSON.parseproduces doubles, so a monetary JSON number is corrupt the moment it is parsed. - The server decides. Permissions, totals, policy verdicts, and state transitions are all server-side. Request DTOs contain no computed totals, statuses, or organisation IDs — the fields do not exist.
- Organisation comes from the session, never from the request. Four independent isolation
layers; cross-tenant access returns
404, never403. - Posted financial records are immutable. Enforced by a database trigger. Corrections are new linked records.
- Every financial or privileged mutation writes an audit event, in the same transaction. Either both commit or neither does.
- Policy is data, not code. One versioned engine serves spend requests, expenses, bills, and purchase orders. A second implementation is a design failure.
- No money arithmetic in the browser. A lint rule enforces it.
- Sandbox providers are labelled as sandbox, in the API and in the UI. We never imply money moved when only a record was created.
Branches: feature/<task-id>-<slug> · fix/… · refactor/… · chore/…
Commits: Conventional Commits with the task ID — feat(auth): add scope predicate builder [1.4.3]
Every pull request must satisfy 19-DEFINITION-OF-DONE.md.
Documentation is updated in the same PR as the behaviour it describes; drift is treated as a bug.
Never commit .env, secrets, API keys, or credentials. Secret scanning runs pre-commit and in
CI, but the first line of defence is not committing them.
Pre-release. See docs/21-CHANGELOG.md and
docs/18-DEVELOPMENT-ROADMAP.md.
Financy holds no compliance certification — not SOC 2, not PCI DSS, not ISO 27001 — and is
not a regulated financial institution.
07-NON-FUNCTIONAL-REQUIREMENTS.md §6 describes the
engineering posture; that is a different claim and is worded as such.