Local-first personal expense tracker built for a single user on a personal machine.
apps/api: private Fastify API (/v1/*) for the PWA and automation.apps/cli: AI-friendly CLI with deterministic JSON envelopes.apps/pwa: mobile-first React + Material UI progressive web app.packages/contracts: shared schemas and envelope contract.packages/db: SQLite + Drizzle schema and migration tooling.packages/domain: business logic used by both API and CLI.packages/analytics: trend summary helpers.packages/integrations-monzo: Monzo API client and payload schemas.tests: Vitest + Supertest + Playwright test suites.
- Runtime: Node.js 22+
- Language: TypeScript end-to-end
- DB: SQLite (WAL, foreign keys on)
- ORM: Drizzle
- API: Fastify + JSON Schema + OpenAPI docs at
/docs- OpenAPI JSON spec:
/docs/json - Operation docs are generated from feature route
schemadefinitions underapps/api/src/features/*/routes.ts
- OpenAPI JSON spec:
- CLI:
commander+ JSON-first contract - PWA: React + Vite + MUI + TanStack Query + installable manifest
- Tooling: Biome (lint + format)
- Node.js 22 LTS (
22.x, see.nvmrc) - pnpm 10+
- Tailscale configured on host and mobile device (for private access)
Recommended first-time bootstrap (installs deps, creates .env if missing, checks/repairs better-sqlite3):
pnpm setup:first-timeManual steps (if you prefer to run them separately):
pnpm installpnpm install also installs local Git hooks (via the root prepare script) so developers get lint/format feedback before code is pushed.
If pnpm prompts to approve dependency build scripts, approve better-sqlite3 (native SQLite addon) and rerun install:
pnpm approve-builds
pnpm installThis repo also allowlists better-sqlite3 in package.json (pnpm.onlyBuiltDependencies) so pnpm can run its native build script during install/rebuild without interactive approval.
pre-commitruns Biome on staged JS/TS/JSON files and auto-fixes formatting/import ordering when possible.pre-pushrunspnpm lint(same lint gate used by CI).- CI still runs
pnpm lint,pnpm typecheck, andpnpm testand remains the source of truth. - Hooks can be bypassed with
git commit --no-verify/git push --no-verify, but CI will still enforce checks.
Verify the native SQLite binding before starting dev servers:
pnpm check:sqliteIf the check fails due to a missing better-sqlite3 binding, run the one-shot repair command:
pnpm repair:sqliteIf you used pnpm setup:first-time, .env is created automatically when missing.
Otherwise:
cp .env.example .envImportant variables:
DB_PATH: SQLite file path (default~/.tithe/tithe.db;~is expanded to your home directory)PORT,HOST: API bind valuesLOG_LEVEL: API logger level (fatal|error|warn|info|debug|trace, defaultinfo)CORS_ALLOWED_ORIGINS: comma-separated allow-list for CORS (default*)VITE_API_BASE: PWA API target (default local:http://127.0.0.1:8787/v1; set Tailnet URL for mobile access)PWA_PORT: PWA dev server port (default5173)PWA_PREVIEW_PORT: PWA preview server port (default4173)MONZO_*: Monzo OAuth settings for connect/sync (MONZO_CLIENT_ID,MONZO_CLIENT_SECRET,MONZO_REDIRECT_URI, optionalMONZO_SCOPE)
pnpm db:migrateLedger v2 note (development only): this release assumes a fresh local DB reset (no backfill for pre-v2 local data). If you are upgrading an existing non-production local database, delete/reset DB_PATH (default ~/.tithe/tithe.db) before running migrations.
pnpm dev:api
pnpm dev:pwa
pnpm dev:cliOr run all workspace dev servers:
pnpm devpnpm dev defaults VITE_API_BASE to http://127.0.0.1:8787/v1 for local development.
Root scripts that depend on SQLite (pnpm dev, pnpm dev:api, pnpm start:api, pnpm db:migrate) run pnpm check:sqlite first and fail fast if the native better-sqlite3 binding is missing.
To test mobile/Tailnet against pnpm dev, override it explicitly, for example:
VITE_API_BASE=http://<your-tailnet-ip>:8787/v1 pnpm devStart built apps separately (after pnpm build):
pnpm start:api
pnpm start:pwa
pnpm start:cliBuild the CLI package and link it globally so tithe is available in your shell:
pnpm --filter @tithe/cli build
pnpm link --global ./apps/cli
exec zsh
tithe --helpIf you do not want to restart the shell, run hash -r before tithe --help.
If tithe is still not found, run pnpm setup, restart zsh, and ensure PNPM_HOME is on your PATH.
To remove the global link later:
pnpm remove --global titheWhen you change CLI code and want the globally linked command to pick up the new build:
pnpm --filter @tithe/cli build
hash -r
tithe --helpIf you want to force-refresh the global link:
pnpm remove --global tithe
pnpm link --global ./apps/cli
hash -rDevelopment note:
dev:apiruns the API directly (node --import tsx src/index.ts) without automatic reload.- API and CLI entrypoints auto-load workspace
.envviadotenvif present (without overriding already exported env vars). - Set
PWA_PORT(for example5174) when another PWA already uses5173. - If you see
better-sqlite3"Could not locate the bindings file", the native addon was not built for your current Node runtime. Use Node22.x, then runpnpm repair:sqliteand rerun your original command. - Fallback repair (if you want the lower-level steps):
pnpm rebuild better-sqlite3orpnpm rebuild --pending better-sqlite3, thenpnpm check:sqlite. - If reinstalling does not help, check
pnpm ignored-builds; pnpm may have auto-ignoredbetter-sqlite3build scripts.
Base path: /v1
GET/POST/PATCH/DELETE /categoriesGET/POST/PATCH/DELETE /expensesGET/POST/PATCH/DELETE /commitmentsPOST /commitments/run-dueGET /commitment-instancesGET /reports/trendsGET /reports/monthly-ledgerGET /reports/category-breakdownGET /reports/commitment-forecastPOST /reimbursements/linkDELETE /reimbursements/link/:idGET /reimbursements/category-rulesPOST /reimbursements/category-rulesDELETE /reimbursements/category-rules/:idPOST /reimbursements/:expenseOutId/closePOST /reimbursements/:expenseOutId/reopenPOST /reimbursements/auto-matchPOST /query/runPOST /integrations/monzo/connect/startGET /integrations/monzo/connect/callbackPOST /integrations/monzo/syncGET /integrations/monzo/status
Success:
{
"ok": true,
"data": {},
"meta": {}
}Error:
{
"ok": false,
"error": {
"code": "ERROR_CODE",
"message": "Human readable",
"details": {}
}
}API error behavior:
- Fastify request validation failures return
400with envelope codeVALIDATION_ERROR. - Unknown routes return
404with envelope codeNOT_FOUND. - Domain
AppErrorfailures preserve their status code and error code in the envelope.
amountMinoris always stored as an absolute integer minor-unit amount.- Direction and meaning come from semantic
expenses.kind(expense|income|transfer_internal|transfer_external) plustransferDirection(in|out) for transfer kinds. - Reports and services should never infer direction from the sign of
amountMinor. - Reimbursement-capable expenses expose:
- stored fields such as
reimbursementStatus,myShareMinor,closedOutstandingMinor - derived fields
recoverableMinor,recoveredMinor,outstandingMinor
- stored fields such as
- Reimbursement auto-match in v2 uses explicit category-link rules (
expense category -> income/transfer category), not hidden grouping keys. GET /reports/monthly-ledgerkeeps legacy sections/totals and adds v2 blocks:cashFlowspendingreimbursements- split transfer sections (
transferInternal,transferExternal)
Use --json for deterministic AI parsing.
tithe --json category list
tithe --json expense list --limit 50
tithe --json report monthly-ledger --month 2026-02
tithe --json commitment run-due
tithe --json reimbursement auto-match
tithe --json reimbursement rule list
tithe webCLI behavior note:
- Running
tithewithout a subcommand prints help and exits successfully. - Database migrations run lazily when a command executes, so help-only invocations do not touch SQLite.
tithe --json report monthly-ledgerdefaults to the current local calendar month if no--monthor--from/--torange is provided.tithe --json expense add/updateaccept--transfer-direction in|outand semantic--kind expense|income|transfer_internal|transfer_external.tithe --json expense add/updatesupport reimbursement fields (--reimbursable/--not-reimbursable,--my-share-minor,--counterparty-type,--reimbursement-group-id).reimbursement-group-idis currently a reserved/deferred field and is not used by the v2 auto-match rule engine.tithe --json category add/updatesupport reimbursement policy fields (--reimbursement-mode,--default-counterparty-type,--default-recovery-window-days).tithe --json reimbursement linkaccepts--idempotency-key; if omitted, the CLI generates a UUID for retry-safe linking.tithe --json reimbursement rule addcreates explicit auto-match rules between one expense category and one inbound category (incomeortransfer).
tithe --json reimbursement rule add --expense-category-id <expenseCategoryId> --inbound-category-id <incomeCategoryId>
tithe --json reimbursement rule list
tithe --json reimbursement rule delete --id <ruleId> --dry-run
tithe --json reimbursement rule delete --id <ruleId> --approve <operationId>
tithe --json reimbursement link --expense-out-id <expenseId> --expense-in-id <incomeId> --amount-minor 2500
tithe --json reimbursement unlink --id <linkId> --dry-run
tithe --json reimbursement unlink --id <linkId> --approve <operationId>
tithe --json reimbursement close --expense-out-id <expenseId> --close-outstanding-minor 500 --reason "Uncollectible remainder"
tithe --json reimbursement reopen --expense-out-id <expenseId>Use tithe web to launch API + PWA together in the foreground:
tithe web
tithe web --mode preview
tithe web --api-port 9797 --pwa-port 5174
tithe --json web --mode devRuntime notes:
--mode devis the default.--mode previewautomatically runs@tithe/apiand@tithe/pwabuilds before starting preview services.tithe webpreserves configuredVITE_API_BASEby default.- If
--api-portis set,tithe webrewrites the port inVITE_API_BASEwhen possible and falls back tohttp://<api-host>:<api-port>/v1. --api-portoverrides APIPORTfor this command.--pwa-portmaps toPWA_PORTin dev mode andPWA_PREVIEW_PORTin preview mode.--jsonemits one startup envelope before live prefixed logs are streamed.- PWA API requests time out after 10 seconds and surface an error state instead of loading indefinitely.
- Request approval token:
tithe --json expense delete --id <expenseId> --dry-run- Execute with token:
tithe --json expense delete --id <expenseId> --approve <operationId>Same pattern applies to category and commitment delete.
Reimbursement unlink (tithe --json reimbursement unlink) uses the same approval-token flow.
Reimbursement category-rule delete (tithe --json reimbursement rule delete) also uses the approval-token flow.
- Intended for mobile, installed from browser as home-screen app.
- Configure
VITE_API_BASEto your machine Tailnet API URL. - API should stay private to Tailnet (no public exposure).
Current status in this implementation:
- OAuth connect flow is implemented (
tithe --json monzo connectreturnsauthUrl). - OAuth callback endpoint is implemented at
GET /v1/integrations/monzo/connect/callback. - OAuth callback stores tokens/connection state only (no automatic sync).
- Manual sync is implemented (
tithe --json monzo sync, with optional--month/--from --towindowing and--override). - Status endpoint is implemented (
tithe --json monzo statusandGET /v1/integrations/monzo/status). - PWA Home screen includes a Monzo card with
Connectplus status/last-sync details (month sync lives on the Monthly Ledger widget). - PWA Home screen embeds a monthly cashflow ledger with month navigation, category breakdown lists, and both
Operating SurplusandNet Cash Movementtotals. - PWA Home Monthly Ledger widget includes
Sync month, which syncs the selected month window and overwrites existing imported Monzo expenses for that month. - PWA Home Monthly Ledger widget also surfaces Ledger v2 summary metrics (
Cash In,Cash Out,Net Flow,True Spend,Reimbursement Outstanding) withGross/NetandExclude internal transferstoggles. - Monthly Ledger sync feedback is month-scoped and clears when you navigate to another month.
- PWA Home includes a single
Add Transactionflow for manualincome,expense, andtransferentries (transfer entries require direction and support semantic subtypeinternal/external). - Reimbursable expense categories in Home manual entry can capture
Track reimbursementplusMy share. - PWA Categories page uses a floating
+button forAdd Category, and opens dialogs for category add/edit (including expense-category reimbursement settings/defaults) and reimbursement auto-match rule management (link expense categories to income/transfer categories). - PWA Home pending commitments support
Mark paid, which creates a linked actual transaction (source=commitment) and updates the monthly ledger. - Home dashboard cards load independently: a ledger/Monzo/commitments fetch error is shown in that card without blocking the entire Home screen.
Connectopens the Monzo OAuth flow in a separate window/tab (opened immediately on click to avoid popup blocking after async API calls).- Initial import window is last 90 days; subsequent sync uses cursor overlap.
- Import policy is settled debit + credit (
amount != 0) only (pending/zero skipped). - Imported Monzo rows use
source=monzoandproviderTransactionId=<transaction_id>for dedupe. - Monzo sync classifies pot transfers as
transfer_internal, non-pot debits asexpense, and non-pot credits asincome. tithe --json monzo sync --override(or PWA Monthly LedgerSync month) overwrites existingmonzorows in place using latest Monzo-derived category/amount/date/kind/merchant fields while preserving local notes and local reimbursement metadata.- Expense API responses include optional Monzo merchant display metadata (
merchantLogoUrl,merchantEmoji) used by the PWA expenses list avatar. - Expense API responses include semantic
kindplus reimbursement fields/derived reimbursement totals for Ledger v2 workflows. - Expense API responses also include
transferDirection(in|out|null); transfer semantic rows require it, income/expense rows returnnull. - PWA expenses list merchant avatars use
logo -> emoji -> initialsfallback for imported Monzo merchants. - Monzo sync best-effort resolves pot-transfer descriptions that are raw Monzo pot IDs (
pot_...) into display labels likePot: Savingsfor new imports; if pot lookup fails or the pot is missing, the raw description is kept. - Merchant logo/emoji metadata is stored for new Monzo imports only (no historical backfill for older imported rows).
- Monzo category mappings are flow-aware (
in|out) and auto-createMonzo: <Category>categories withexpense/incomekind inferred from flow. Pot transfers use a dedicated transfer category. - v2 does not auto-create reimbursement auto-match rules from Monzo categories; users configure category-link rules manually in Categories.
- If Monzo denies permissions during sync/account access (
forbidden.insufficient_permissions), Tithe surfaces a sync error and preserves the message in Monzo statuslastErroruntil a later successful sync clears it.
Typical local flow:
tithe --json monzo connect
# Open returned data.authUrl in browser and approve access
# Monzo redirects back to /v1/integrations/monzo/connect/callback (stores tokens only; no auto-sync)
tithe --json monzo status
tithe --json monzo sync
tithe --json monzo sync --month 2026-02 --override
tithe --json monzo sync --from 2026-02-01T00:00:00Z --to 2026-03-01T00:00:00Z --overrideCashflow ledger and transfer examples:
tithe --json report monthly-ledger --month 2026-02
tithe --json expense add --occurred-at 2026-02-10T09:00:00Z --amount-minor 100000 --currency GBP --category-id <transfer-category-id> --kind transfer_internal --transfer-direction out
tithe --json expense add --occurred-at 2026-02-11T09:00:00Z --amount-minor 3600 --currency GBP --category-id <sports-category-id> --reimbursable --my-share-minor 1200
tithe --json reimbursement link --expense-out-id <fronted-expense-id> --expense-in-id <incoming-repayment-id> --amount-minor 2400PWA flow:
- Open Home page in the PWA.
- Use
Connectto start OAuth (opens Monzo auth URL in a new window/tab). - After OAuth callback completes (and any in-app permission approval is done), return to PWA Home and use
Sync monthin the Monthly Ledger widget for the month you want to import/refresh.
Current baseline:
- SQLite file is stored at
DB_PATH. - Use file-level copy when API/CLI are stopped.
- Encryption workflow and automated backup jobs are planned for Milestone 4.
Run lint/type/test:
pnpm lint
pnpm typecheck
pnpm testAdditional mobile E2E suite:
pnpm --filter @tithe/tests test:pwaCATEGORY_IN_USE: pass reassign category or move linked expenses/commitments first.APPROVAL_REQUIRED: destructive action was attempted without dry-run approval token.INVALID_RRULE: commitment recurrence rule format is invalid.APPROVAL_EXPIRED: run dry-run again and use the new token.Could not locate the bindings file(better-sqlite3): runpnpm rebuild better-sqlite3.
- Milestone 1: monorepo, DB migrations, API/CLI/PWA shell, Biome, docs.
- Milestone 2: category/expense/commitment features + safety gates + mobile flows.
- Milestone 3: Monzo OAuth + import + sync lifecycle hardening.
- Milestone 4: analytics expansion, encrypted backups, hardening.