Self-hosted internal accounting (內外帳) & client-finance management for small companies.
One deployment serves many organizations, with every row scoped by organization_id.
It keeps two sets of books — internal and external (內外帳) — in one ledger, and tracks the money owed to you on the other side: projects, contracts, subscriptions, invoices and who still hasn't paid. It's the in-house tool we run ourselves, not a general-purpose ERP.
The UI ships in English and Traditional Chinese (zh-TW), switchable per user from the sidebar. The codebase, docs and configuration are English.
Screenshots are from a demo organization — the company, clients and figures are all made up.
There is also a landing page that walks through the
same ideas — it is docs/index.html, published with GitHub Pages.
- Multi-tenancy — one deployment, many organizations, via the
better-auth organization plugin. Every row is scoped by
organization_id, with an org switcher and owner/admin roles. - Dual books (內外帳) — a
bookfield on every transaction separates the internal and external ledgers. - Accounting core — transactions, categories, bank accounts, counterparties, reconciliation, advances and reimbursements.
- Client finance — projects, contracts (with instalment schedules), subscriptions and receivables.
- Billing board (請款看板) — contract instalments and subscription periods merged into one actionable list.
- Google Calendar — billing, collection and invoicing dates are pushed to a dedicated reminder calendar, so the nagging comes from Google. No cron needed.
- Payroll — employees, payroll item types and pay runs.
- Reports — receivables ageing, VAT periods, contract coverage, cost and project P&L.
- Documents — file uploads to object storage (e.g. receipts attached to transactions).
- MCP — the ledger is also exposed as an MCP server, so an AI agent can query and record
against it. See
docs/mcp.md. - Bilingual UI — English and Traditional Chinese, switched per user without reloading or changing URLs. See Internationalization.
- Auth — Google OAuth via better-auth, invite-based member management, org settings.
| Layer | Choice |
|---|---|
| Frontend | Next.js 16 (App Router), React 19, Tailwind CSS v4, shadcn/ui + Base UI |
| i18n | next-intl (cookie-based locale, no URL segment) |
| Backend | Next.js Route Handlers / Server Actions, better-auth |
| Database | Postgres + Drizzle ORM (introspect-only schema, plain-SQL migrations) |
| Tooling | Bun |
Nothing is tied to a specific host — it's a standard Next.js + Postgres app. Where we happen to run it is just one option; see Deployment.
Prerequisites: Bun, a Postgres database, and Google OAuth credentials.
bun installcp .env.example .env.local # DATABASE_URL, BETTER_AUTH_SECRET, Google OAuth…bun dev # http://localhost:3000Generate BETTER_AUTH_SECRET with openssl rand -base64 32. For Google OAuth, create a
"Web application" OAuth client and add http://localhost:3000/api/auth/callback/google as
an authorized redirect URI (add your production URL before deploying).
The first user to sign in lands on /onboarding. To make someone the owner of an existing
organization, edit v_email in scripts/bootstrap-owner.sql
and run it against your database.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string (pooled) |
BETTER_AUTH_SECRET |
Session signing secret (openssl rand -base64 32) |
BETTER_AUTH_URL |
App base URL, no trailing slash |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
Google OAuth credentials (sign-in and the optional Google Calendar integration) |
See .env.example for the full template.
The UI ships in English and Traditional Chinese (zh-TW). Readers switch locale from
the sidebar; the choice is stored in a locale cookie.
There is deliberately no locale segment in the URL — /billing is /billing in every
language. That keeps the auth callbacks, the MCP endpoints and every bookmark stable.
src/i18n/
config.ts # locale list, default, cookie name
request.ts # next-intl request config
messages/
dictionary.ts # Leaf / Dictionary types + locale projection
<area>.ts # one file per area, all locales side by side
Every string carries its translations together, rather than living in parallel per-locale trees:
const dashboard = {
title: { "zh-TW": "總覽", en: "Overview" },
} satisfies Dictionary;A key missing a locale — or carrying a misspelled one — is a build error, not a string
that silently falls back. Keeping both languages on the same key also makes a gap visible
where you are already reading, and means adding a locale is a compiler-guided edit rather
than a diff between two files. Translations are read with
next-intl — useTranslations() in Client Components,
await getTranslations() in Server Components and Server Actions. Server Actions translate
their own validation errors, so a message that surfaces in a toast is in the same language as
the page that triggered it.
To add a locale: add it to locales in src/i18n/config.ts and let TypeScript walk you
through every key that now needs a translation.
Postgres through Drizzle ORM. src/db/schema.ts is introspected from the database
(bun run db:pull) — the app never pushes schema — and everything under
migrations/ is plain forward-only SQL. There is no vendor-specific SQL, so
any Postgres works.
The Drizzle client lives in src/db/index.ts — point DATABASE_URL at
your database and you're set. Which Postgres driver to use is the only runtime-dependent
choice (a TCP driver like node-postgres on a normal server; an HTTP driver where you can't
open TCP). That single swap is covered in the
deployment guide.
migrations/is a forward-only history for an existing database, not a bootstrap for an empty one. How you evolve schema, branch your database or roll out migrations is up to your own workflow — this project does not prescribe one.
Build it (bun run build) and run it like any other Next.js app, anywhere that can serve
Next.js and reach a Postgres database. The repo prescribes no host.
The deployment guide walks through two concrete setups as examples
— a Docker self-host (vendor-neutral Node + Postgres, with a Dockerfile
and docker-compose.yml), and the serverless setup we happen to run
— but neither is a requirement.
src/
app/ # Next.js App Router (dashboard pages, auth routes, onboarding)
components/ # UI components (shadcn/ui + Base UI) and shared widgets
db/ # Drizzle client, introspected schema, queries & mutations
i18n/ # locale config and bilingual message catalogue
lib/ # auth (better-auth), session helpers, object storage, utils
migrations/ # plain forward-only SQL migrations
scripts/ # one-off operational SQL (e.g. bootstrap-owner)
docs/ # landing page (GitHub Pages root), deployment guide, MCP docs, assets
Dockerfile # vendor-neutral self-host image
docker-compose.yml
Issues and pull requests are welcome — please use GitHub Issues for bugs and feature requests. User-facing strings must go through i18n rather than being hardcoded — see Internationalization for where the message files live.
Licensed under the Apache License 2.0. You're free to use, modify and redistribute it, including commercially — just retain the license and copyright notices.



