A simple personal finance tracker built with React + TypeScript (frontend) and Node.js + Express + MySQL (backend).
Great for tracking income, expenses, budgets, savings/investments, and recurring bills — with charts and export/import.
- 🔐 Register & Login (passwords hashed with bcrypt) — registration is admin-only (see User Management)
- 🔑 Two-Factor Authentication — optional TOTP, authenticator QR setup, one-time recovery codes, and admin reset
- 📊 Dashboard: income vs expense, balance summary, recent transactions
- 💵 Income: CRUD + categories + payment methods
- 💸 Expenses: CRUD + categories + payment methods
- 🧾 Expense receipts: attach JPG, PNG, WebP, or PDF evidence (up to 5 MB)
- 🎯 Budgets per category and month, with optional positive-balance rollover
- 🐷 Savings / Investments tracking
- 📅 Bills (recurring) + payment history
- 🏦 Planning: net worth, debts/receivables, payment history, and smart categorization rules
- 🔔 Notifications: bill, debt, and budget alerts with optional SMTP email delivery
- ↩️ Activity & Trash: audit history and 30-day restore for deleted finance records
- 📈 Insights & Heatmap (spending patterns)
- 🧾 Reports + Export to PDF
- ♻️ Export/Import JSON for backup/restore
- Admins can view all users, update roles, reset passwords/TFA, and create new users via inline modal
- Self-registration is disabled; only administrators can create accounts
- Dates (e.g., Created At) are displayed in the server's local timezone for consistency
- All timestamps stored in MySQL use the server's timezone
- Frontend displays dates (e.g., user Created At) in DD/MM/YYYY format based on server timezone, ensuring consistent date display regardless of client location
- ✅ Base currency: IDR (amounts are stored in IDR)
- 🌍 Choose a display currency (IDR, USD, EUR, GBP, SGD, JPY, and more)
- 🔄 Automatic conversion using the latest exchange rates + a Refresh rates action
- 🕒 Rates are cached (lightweight and fast), with manual refresh whenever needed
- 🌐 Bilingual UI — Bahasa Indonesia (
id) and English (en), selectable per user - ⚙️ Auto sync & auto translate for missing keys, with optional changed-key retranslation
- 🐳 LibreTranslate via Docker support for auto-translation without paid APIs (optional)
- Frontend: React 18, TypeScript, Vite, Tailwind, shadcn/ui, Zustand
- Charts: Recharts
- PDF Export: jsPDF + jspdf-autotable
- Currency: Zustand store + persisted settings + exchange rates (public currency rates API)
- Backend: Node.js, Express, mysql2, bcryptjs, uuid
- Container: Docker + Docker Compose (Nginx serves the frontend + reverse-proxy
/api)
my-local-wallet/
├─ src/ # React app
├─ backend/ # Express API + MySQL schema/init
├─ docker-compose.yml # app + backend + mysql (+ libretranslate + i18n tools)
├─ nginx.conf # proxy /api -> backend
├─ Dockerfile # build React -> serve via Nginx
├─ backend/Dockerfile # backend image
├─ .env.example # env template (copy to .env)
└─ README_DOCKER.md # Docker quick notes
- Copy the env file:
cp .env.example .env- Edit
.env(at minimum: MySQL credentials). Example:
WEB_PORT=3000
MYSQL_ROOT_PASSWORD=change_me_root
MYSQL_DATABASE=finance_db
MYSQL_USER=finance_user
MYSQL_PASSWORD=change_me_password
JWT_SECRET=generate_a_long_random_value
TFA_ENCRYPTION_KEY=generate_a_different_long_random_value
COOKIE_SECURE=
BCRYPT_ROUNDS=12
SESSION_IDLE_TIMEOUT_MINUTES=30- Run:
docker compose up -d --build- Open:
- Frontend: http://localhost:3000
- Backend health: http://localhost:3000/api/health
Note: In Docker, the frontend uses
VITE_API_URL=/apiand Nginx proxies/api/*to the backend container.On a fresh database, the first page is a one-time setup wizard. It creates the first administrator, enforces the password policy, requires TFA verification, and issues recovery codes. No default username or password is created.
Configure any SMTP provider in .env, then rebuild the backend:
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=wallet@example.com
SMTP_PASSWORD=your_smtp_password
SMTP_FROM=wallet@example.com
APP_URL=https://wallet.example.com
NOTIFICATION_SCAN_INTERVAL_MS=21600000- Use port
587withSMTP_SECURE=falsefor STARTTLS, or port465withSMTP_SECURE=true. - For providers that require two-factor authentication, use an SMTP/API app password instead of the account password.
- In Planning → Notifications, enter the recipient email, enable email delivery, save, and use Test email.
- The backend scans automatically every six hours by default. The minimum supported interval is one minute.
Apply changes:
docker compose up -d --build backendDocker stores receipt files in the persistent receipts_data volume. JSON export version 4 includes net worth, debts, debt payments, and categorization rules, but does not embed receipt binaries. Include both mysql_data and receipts_data in server-level backups when receipt evidence must be recoverable.
docker compose down -v
docker compose up -d --build- Node.js 18+ (Docker uses Node 20)
- MySQL 8.0+
cd backend
npm install
# set env then run:
MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=root MYSQL_PASSWORD=your_password MYSQL_DATABASE=finance_db npm run devBackend runs on: http://localhost:3001
Open a new terminal:
npm install
# point frontend to backend:
export VITE_API_URL="http://localhost:3001/api"
npm run devFrontend runs on: http://localhost:5173
VITE_API_URL
Base URL for the backend API.- Local dev:
http://localhost:3001/api - Docker:
/api(default in Dockerfile)
- Local dev:
WEB_PORT(default3000) — exposed port for the web UIMYSQL_ROOT_PASSWORDMYSQL_DATABASEMYSQL_USERMYSQL_PASSWORDMYSQL_HOST(Docker usesmysql)MYSQL_PORT(default3306)JWT_SECRET— required signing key for short-lived TFA challengesTFA_ENCRYPTION_KEY— recommended separate key for encrypting TOTP secrets at rest; keep this value stable after users enable TFACOOKIE_SECURE— set totruewhen the public app is served through HTTPS
- All amounts are stored in IDR, then displayed in the user-selected display currency.
- Rates are fetched from a public exchange-rates API and cached to reduce repeated calls.
- If rates cannot be fetched (e.g., offline), the app still works and falls back to IDR / last-known rates.
GET /api/healthPOST /api/auth/registerPOST /api/auth/loginGET /api/auth/sessionPOST /api/auth/logoutPOST /api/auth/tfa/verify-loginGET /api/auth/tfa/statusPOST /api/auth/tfa/setupPOST /api/auth/tfa/confirmPOST /api/auth/tfa/disablePUT /api/backup/restore- CRUD:
/api/incomes/*/api/expenses/*/api/budgets/*/api/savings/*/api/master_data/*/api/bills/*/api/bill_payments/*
- Passwords are hashed (bcrypt) before being stored.
- New passwords require at least 12 characters with uppercase, lowercase, and numeric characters.
- Fresh installations have no default credentials; the first administrator and mandatory TFA are created through the one-time setup wizard.
- TOTP secrets are encrypted with AES-256-GCM and recovery codes are stored only as keyed hashes.
TFA_ENCRYPTION_KEYis required and must be different fromJWT_SECRET.- Login TFA challenges expire after five minutes and cannot be used as API access tokens.
- Login sessions are opaque, revocable per device, stored in MySQL, rotated daily, and expire after 30 idle minutes.
- Leave
COOKIE_SECUREempty to derive it fromX-Forwarded-Proto, or force it totrueon HTTPS-only deployments. - Authenticated write requests use a per-session CSRF token.
- JSON restore is validated and committed atomically; encrypted backup files use password-derived AES-256-GCM.
- Browser backups require a password and are never stored as plaintext.
- For production: change DB passwords, and run behind HTTPS (reverse proxy like Nginx/Caddy/Traefik).
- CORS is restricted through
ALLOWED_ORIGINS.
Base translations live in:
src/locales/id.json✅ (source of truth)
Other languages:
src/locales/en.json,es.json,fr.json,de.json,pt.json,ru.json,ar.json,hi.json,zh.json,ja.json,ko.json
- Add/update keys in
src/locales/id.json - Run the app:
npm run devThe system will:
- sync locale files (create missing language files)
- auto-translate new/changed keys (when a translator service is available)
npm run i18n:sync
npm run i18n:translate
npm run i18n:autoIf you’re using Docker Compose, you can enable LibreTranslate for automatic translations without a paid API.
In docker-compose.yml, these services are already included:
libretranslatei18n_bootstrap(one-shot generation)i18n_watcher(periodic sync/translate)
Default provider: LibreTranslate (free). (Optional) you can switch to another provider via the
TRANSLATE_PROVIDERenv variable.
See LICENSE.