An open-source, self-hosted ledger for indie SaaS and side projects. Track every dollar in and out, watch MRR, burn, runway, and net month over month — without shipping your numbers to a third party.
Single-user, single binary's-worth of dependencies, single SQLite file you own.
- Features
- Stack
- Quickstart
- Scripts
- Configuration
- Concepts
- Architecture
- Data model
- Design system
- Export & import
- Deployment
- FAQ
- License
- Multiple projects — each with its own color, entries, recurring rules, and per-project dashboard.
- Aggregate dashboard — KPIs across the whole portfolio: Net YTD, MRR, gross burn, net-burn runway, and lifetime totals.
- Monthly chart — trailing 12 months of income, expense, and net (composed bars + net line), with a custom paper-card tooltip.
- Income & expense breakdowns — twin trailing-12-month category breakdowns showing where the money comes from and where it goes.
- Recurring rules — monthly subscriptions and recurring revenue auto-materialize into past entries. Editing a rule propagates field changes (amount, category, type, currency, note) to past entries automatically. A Regenerate button restores entries you deleted manually.
- Archive & hard-delete — projects can be archived (hidden from the dashboard, preserved for restore) or permanently deleted (cascades to entries and rules).
- Multi-currency — entries in any of 13 supported ISO currencies, aggregated
to your base currency at a static FX rate (editable in
lib/fx.ts). - Export & import — single-file JSON backup that round-trips losslessly,
preserving IDs and foreign keys.
curl-able for automated backups. - Custom design system — hand-rolled components, no shadcn/Mantine copy-paste. Bricolage Grotesque + JetBrains Mono on warm paper.
- No accounts, no telemetry, no SaaS — your data lives in a SQLite file you
own. Run it on
localhost, in a Docker container, on a Pi — wherever.
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16 (App Router, Server Actions) | First-class server actions for forms; statically-rendered dashboard |
| Runtime | React 19 | useActionState, server components |
| Styling | Tailwind CSS v4 | @theme inline for hand-rolled tokens, no config file |
| Language | TypeScript 5, strict | Drizzle gives end-to-end types from DB to UI |
| DB | SQLite via better-sqlite3 |
Synchronous, fast, zero infra, perfect for single-user |
| ORM | Drizzle ORM + drizzle-kit | Typed queries, generated migrations |
| Charts | Recharts | Composable enough to fully restyle |
| Forms | Native <form> + server actions + useActionState |
No react-hook-form needed |
| Validation | Zod | Shared schemas between form + server action |
| Dates | date-fns | Tree-shakable, sane API |
| Package manager | pnpm | Fast, content-addressable |
git clone <repo>
cd bottomline
pnpm install
pnpm db:migrate # creates ./data/bottomline.db
pnpm db:seed # optional — 2 demo projects, 12 months of activity
pnpm devOpen http://localhost:3000.
Note: This project uses Next.js 16.
paramsandsearchParamsare Promises and must beawaited in server components. Tailwind v4 uses@import 'tailwindcss';inglobals.css, not the old@tailwinddirectives.
| Script | Purpose |
|---|---|
pnpm dev |
Start the dev server (Turbopack) on port 3000 |
pnpm build |
Production build |
pnpm start |
Run the production server |
pnpm lint |
ESLint (flat config) |
pnpm typecheck |
tsc --noEmit |
pnpm db:generate |
Generate a new Drizzle migration from db/schema.ts |
pnpm db:migrate |
Apply pending migrations to the SQLite file |
pnpm db:seed |
Wipe and reseed with demo data |
pnpm db:studio |
Drizzle Studio (browse the DB in a GUI) |
A single env var lives in .env.local:
DATABASE_PATH=./data/bottomline.db
That's it. The data/ directory is gitignored.
Available under Settings:
| Setting | Description |
|---|---|
| Base currency | All aggregates convert into this currency. Default USD. |
| Cash on hand | Used as the numerator in the runway calculation. |
| Fiscal year start month | For future fiscal-year aggregations. |
| Categories | Add/rename/delete; income or expense kind, per-category color. |
| Export & import | Download a JSON backup or replace all data from one. |
Stored everywhere as amount_cents INTEGER. SQLite's REAL is a 64-bit
float and accumulates rounding error on summed financials — so no floats, ever.
Display uses Intl.NumberFormat.
A project groups everything: entries, recurring rules, color, an optional description and launch date.
- Archive — sets
archived_at. Hidden from the dashboard and aggregates. Restorable from Projects → Archived (or via the project's edit page). - Delete permanently — cascades through entries and recurring rules. Available from the edit page and the archived list. Not undoable.
The slugifier reserves new, archived, edit, settings so a project name
can't collide with a route.
A single table for income and expense (type column). One entry = one
transaction. Each has:
- A
type,amount_cents,currency - A
project_id(required) - An optional
category_id - An
occurred_ondate (the canonical date used for monthly bucketing) - An optional
note - An optional
source_rule_idpointing at the recurring rule that generated it
Monthly subscriptions, recurring revenue, anything that repeats at a fixed day-of-month. Rules generate entries — they don't replace them. Each rule defines amount, type, currency, category, day-of-month, start date, optional end date.
On every dashboard read, the app runs an idempotent
materializeRecurring() that inserts any missing entries for active rules.
Forward-only semantics: only months strictly after the latest existing entry for each rule are filled in. This means manually deleting an entry sticks — auto-materialization won't resurrect it.
When you save a rule edit, the following fields propagate to all materialized entries from that rule:
| Field | Propagates? |
|---|---|
type |
yes |
amount_cents |
yes |
currency |
yes |
category_id |
yes |
note |
yes |
day_of_month |
no (would silently rewrite history) |
starts_on, ends_on |
no |
The save message tells you how many entries were touched ("Saved. Updated 8 past entries to match.").
The Regenerate button on each rule does a full gap-fill — inserts any
(rule, month) that's currently missing, including ones you deleted manually or
months exposed by extending starts_on backwards. Idempotent.
The rule is removed. Past entries are kept — their source_rule_id is set
to NULL and they continue to behave like ad-hoc entries from then on.
Per-entry classification. Two kinds: expense (the default) and income. Each
category has a color used in the breakdown panels.
To get a useful "Where the money comes from" breakdown, add a few
income-kind categories under Settings → Categories and assign them to
your income rules. The field propagation will push the category onto past
entries automatically.
Monthly Recurring Revenue, not "monthly income." Computed two ways:
- Primary: sum of active recurring income rules' monthly amounts.
- Fallback (when a project has no income rules): last full calendar month's income.
The MRR tile caption tells you which one is being shown.
Gross monthly expense — i.e., cash going out, not netted against revenue. Industry-standard definition. Computed as the average over the trailing 3 months.
A €60 domain renewal will spike this by €20/mo for three months even if your steady-state spend is much lower — check the entries table if burn looks off.
Net-burn-based, the industry-standard definition:
runway = cash on hand ÷ (avg monthly expense − avg monthly income, trailing 3mo)
- If net burn ≤ 0 (break-even or profitable), Runway shows ∞ with a green Profitable badge.
- Otherwise, months are shown, plus a caption explaining the math: "Cash on hand: €100 · burns €2.95/mo net".
This is the realistic runway. If you want a stress-test of "what if revenue goes to zero tomorrow," divide your cash by the gross Burn tile directly.
Entries can be in any of the supported currencies. Aggregates convert into
your base currency via a static rate table in lib/fx.ts. Editing rates
requires editing the file and restarting the server.
Supported: USD, EUR, GBP, CAD, AUD, CHF, JPY, INR, BRL, TRY, MXN, SGD, ZAR.
Future: wire a daily-cached free FX API (e.g. open.er-api.com). For v1 the static table is good enough for a self-hosted single-user app.
app/
layout.tsx # Fonts, <TopNav>, footer
page.tsx # Aggregate dashboard
globals.css # @theme inline { ... } design tokens
loading.tsx, error.tsx, not-found.tsx
api/
export/route.ts # GET full-DB JSON download
projects/
page.tsx # List (with ?show=archived toggle)
new/page.tsx
[slug]/
page.tsx # Project dashboard
edit/page.tsx # Edit + archive + hard-delete
entries/page.tsx # Paginated entries + type filter
entries/new/page.tsx
recurring/page.tsx # Manage rules
recurring/[id]/edit/page.tsx # Edit a rule
settings/
page.tsx # Index
general/page.tsx # Base currency, cash, fiscal year
categories/page.tsx # CRUD categories
data/page.tsx # Export + import
components/
nav/ TopNav, NavLink (active-route logic, client)
ui/ Button, Card, Field, Input, Select, CurrencyInput,
SegmentedControl, Tag, EmptyState, Skeleton
dashboard/ StatTile, Sparkline, MonthlyChart, CategoryBreakdown,
RunwayBar, ProjectCard
entries/ EntryRow, EntryTable, EntryForm, EntryFilters,
RecurringForm
projects/ ProjectForm, SettingsForm, CategoryEditor,
ArchivedProjectRow, ImportForm
db/
schema.ts # Drizzle table definitions
client.ts # singleton better-sqlite3 (WAL, FK on)
migrate.ts # tsx-runnable migration runner
migrations/ # generated SQL
seed.ts # demo data
lib/
actions/ # 'use server' mutations
projects.ts, entries.ts, recurring.ts,
categories.ts, settings.ts, data.ts (import)
_shared.ts # ActionState + zod-from-FormData helpers
queries/ # server-only typed reads
projects.ts, entries.ts, materialize.ts
metrics.ts # pure: monthlySeries, mrr*, burnRate,
# netBurnRate, runwayMonths,
# categoryBreakdown, ytdNet,
# lifetimeTotals, monthsToProfitability
recurring.ts # pure: expandRule,
# pendingForwardMaterializations,
# pendingMaterializations
money.ts # cents <-> major, Intl.NumberFormat
date.ts # monthKey, monthsInRange, ranges
fx.ts # static rate table + convert()
settings.ts # KV settings helpers
slug.ts # slugify + reserved-name list
validators.ts # zod schemas shared by forms + actions
env.ts # zod-validated process.env
cn.ts # tiny classname joiner
data/ # gitignored
bottomline.db # the database file
projects id, slug UNIQUE, name, description, color, launched_on,
archived_at, created_at
categories id, name UNIQUE, kind ('expense'|'income'), color, sort_order
entries id, project_id FK, type ('income'|'expense'),
amount_cents, currency, category_id FK?, occurred_on,
note, source_rule_id FK?, created_at
-- indexes: (project_id, occurred_on), (type, occurred_on),
-- (source_rule_id)
recurring_rules id, project_id FK, type, amount_cents, currency,
category_id FK?, cadence ('monthly'), day_of_month,
starts_on, ends_on?, note, active, created_at
settings key PK, value
-- known keys: base_currency, cash_on_hand_cents,
-- fiscal_year_start_month
Foreign-key cascades:
| Parent | Child | On delete |
|---|---|---|
projects |
entries |
cascade |
projects |
recurring_rules |
cascade |
categories |
entries.category_id |
set null |
categories |
recurring_rules.category_id |
set null |
recurring_rules |
entries.source_rule_id |
set null |
So deleting a project removes its entries and rules. Deleting a category or rule leaves its entries intact and uncategorized / orphaned.
- Display / sans: Bricolage Grotesque (variable, latin subset). Confident, humanist, distinctive at large sizes, readable at body sizes.
- Mono / numerics: JetBrains Mono. Tabular by default, unambiguous zeros
and ones — every number in the UI is in mono with
tabular-nums.
Loaded via next/font/google in app/layout.tsx, exposed as
--font-display and --font-mono CSS variables.
A warm-paper ledger feel, no stoplight UI:
| Token | Hex | Purpose |
|---|---|---|
--color-paper |
#f5efe2 |
Page background — warm ivory |
--color-card |
#fbf7ec |
Card surface, slightly lighter |
--color-ink |
#1a1714 |
Primary text |
--color-muted |
#6b6357 |
Secondary text, axis labels |
--color-hairline |
#d8cfb8 |
1 px borders |
--color-olive |
#6b6a2a |
Primary accent — buttons, links, focus |
--color-gain |
#2f6a3a |
Deep moss green — net positive |
--color-loss |
#a8472b |
Terracotta — net negative |
Plus tint/soft variants for backgrounds and an 8-color categorical palette for
charts. Defined in app/globals.css under @theme inline { ... }.
- Hairline borders on every surface, 6px rounded corners (not pill, not sharp).
- Subtle paper-grain overlay on
body::before(pure CSS radial gradients). - Custom focus rings (
outline-2 outline-offset-2 outline-olive). - Recharts entirely restyled — dashed horizontal grid, mono ticks, Card-style tooltip, gain-soft and loss-soft gradient bars, ink-color net line.
prefers-reduced-motionguard kills animations.
GET /api/export returns a single JSON file:
{
"version": 1,
"exportedAt": "2026-05-13T08:58:53.509Z",
"projects": [ { "id": 3, "slug": "...", ... }, ... ],
"categories": [ { "id": 1, "name": "Hosting", "kind": "expense", ... }, ... ],
"recurring_rules": [ ... ],
"entries": [ ... ],
"settings": [ { "key": "base_currency", "value": "EUR" }, ... ]
}Every row, every column, IDs included. Filename is
bottomline-YYYY-MM-DD.json.
curl -o "bottomline-$(date +%F).json" http://localhost:3000/api/exportDrop that in cron for daily backups.
Settings → Export & import → Replace all data. Hard-replace semantics:
- zod validates the file's shape before touching anything.
- Everything happens inside a single SQLite transaction. Partial failure rolls back.
- All tables wiped in dependency order; rows reinserted with original IDs so foreign keys stay valid.
sqlite_sequenceis bumped tomax(id)per table so the next auto-insert doesn't collide.- A browser confirm dialog guards the destructive action.
50 MB file cap — way above any realistic dataset.
This is a single-binary-ish Node app. Options in order of simplicity:
- Bare metal / Pi / homelab:
pnpm build && pnpm start. Run it behind Caddy / nginx / Cloudflare Tunnel. Mount a volume fordata/. - Docker: build a standard Node image, copy the project, expose port 3000,
volume-mount
/app/data. (No Dockerfile shipped — write one if you need it.) - Vercel and friends: not recommended for the default SQLite setup —
serverless filesystems are ephemeral. If you must, point
DATABASE_PATHat Turso (libSQL) or swapbetter-sqlite3for@libsql/client+ Drizzle's libSQL driver indb/client.ts.
Back up your DB. This app is self-hosted; nobody else is. Either curl
the export endpoint on a schedule (see above) or back up the data/ directory
directly.
Why is MRR €17 but May's income on the chart is €40? MRR is the recurring part — only active recurring income rules count. A one-off €23 consulting payment shows on the chart but not in MRR.
Why is my Net YTD negative but the mo/mo delta chip is green?
The chip shows the change from the prior month. Going from -€30 to -€20
is a +€10 improvement, even though both numbers are losses.
I edited a rule but old entries look the same.
Refresh the page — server-component caches revalidate on the action's
revalidatePath. If the field doesn't propagate, it's a date field
(day_of_month, starts_on, ends_on) — those are intentionally left alone.
I deleted a recurring rule. Where did its past entries go?
They're still there in the entries table, just orphaned (their
source_rule_id is now NULL). The "↻ recurring" tag disappears because
there's no rule to point at.
Can I export to CSV? Not yet. JSON only. Drop a feature request if you need it.
Where do I change the FX rate table?
lib/fx.ts. Edit the rates relative to USD and restart the server.
Does dark mode work? No, light only in v1.
MIT.