diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe67774 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# Keep the build context small and the image clean/secure. + +# Deps — reinstalled inside the image from the lockfile. +node_modules +npm-debug.log* + +# Local SQLite database & uploads — these must NOT be baked into the image. +# At runtime they live on the mounted named volume (/app/data). +data +uploads +*.db +*.sqlite +*.sqlite3 + +# Secrets — never copy real env files into the image. +.env +.env.local +.env.*.local + +# VCS / CI / editor noise. +.git +.gitignore +.github +.idea +.vscode +*.swp +*.swo + +# Tests are not needed at runtime (run them in CI instead). +test +jest.config.js +coverage + +# Mobile shells & their build artifacts (server image doesn't need them). +android +ios +capacitor.config.json +*.apk +*.aab +*.ipa + +# Logs & OS cruft. +logs +*.log +.DS_Store +Thumbs.db + +# Docker / deploy meta (no need to copy into the image). +Dockerfile +.dockerignore +docker-compose.yml +scripts/deploy.sh +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f3a7fad --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# ============================================================================ +# FINMAN — environment configuration +# Copy to `.env` and fill in real values: cp .env.example .env +# NEVER commit a real .env (it is gitignored). Generate strong secrets, e.g.: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# ============================================================================ + +# ---- Core server ---------------------------------------------------------- +# Port the app listens on (host port in docker-compose mirrors this). +PORT=3000 +# development | production. In production the secrets below are REQUIRED +# (validateEnv() and config.js fail-fast instead of auto-generating dev keys). +NODE_ENV=production + +# ---- Secrets (REQUIRED in production) ------------------------------------- +# JWT signing secret for Passport-JWT auth tokens. +# gen: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +JWT_SECRET=change-me-32-byte-hex +# express-session cookie secret. +# gen: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET=change-me-32-byte-hex +# AES-256 key for encrypting stored bank API tokens. MUST be 64 hex chars (32 bytes). +# gen: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +ENCRYPTION_KEY=change-me-64-hex-chars + +# ---- Database ------------------------------------------------------------- +# SQLite file path. Default for local dev is ./data/finance.db. +# In Docker this is forced to /app/data/finance.db (persistent named volume). +DATABASE_PATH=./data/finance.db + +# ---- CORS (optional) ------------------------------------------------------ +# Allowed origin for browser requests. Leave unset to use the per-env default +# (http://localhost:3000 in dev, disabled in production). Set to your domain +# in production, e.g. https://finman.example.com +# CORS_ORIGIN=https://finman.example.com + +# ---- Logging (optional) --------------------------------------------------- +# pino log level: trace|debug|info|warn|error|fatal|silent. Default: info. +# LOG_LEVEL=info + +# ---- AI assistant (optional — feature stays 503 until configured) --------- +# Provider-agnostic AI layer (lib/ai/provider.js). +# AI_PROVIDER: anthropic | openai | ollama (default: anthropic) +AI_PROVIDER=anthropic +# API key for the chosen provider. Without it, AI endpoints return +# 503 AI_NOT_CONFIGURED. (Ollama local typically needs no key.) +AI_API_KEY= +# Model id, e.g. claude-3-5-sonnet-latest | gpt-4o-mini | llama3.1 +AI_MODEL= +# Override the provider base URL (e.g. http://localhost:11434 for Ollama, +# or a proxy/gateway). Optional; provider defaults are used when empty. +AI_BASE_URL= + +# ---- Bank auto-sync (optional) -------------------------------------------- +# Master switch for scheduled bank synchronization (Monobank/Revolut/Tinkoff). +# Set to "true" to enable; per-bank access tokens are added by each user in-app +# (stored encrypted with ENCRYPTION_KEY). Manual CSV import works without this. +SYNC_ENABLED=false + +# ---- Billing / Stripe (optional — billing stays disabled until set) ------- +# Stripe secret key (sk_live_... / sk_test_...). Required for the billing +# (subscription tier upgrade) flow; without it billing endpoints are inactive. +STRIPE_SECRET_KEY= +# Stripe webhook signing secret (whsec_...) for verifying webhook events. +STRIPE_WEBHOOK_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b7592db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +# Run on pushes and PRs targeting the main feature branches. +on: + push: + branches: [main, master, feat/finman-overhaul] + pull_request: + +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint deps & run tests (Node 22) + runs-on: ubuntu-latest + + env: + NODE_ENV: test + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # Reproducible install from the lockfile (includes devDeps: jest/supertest). + - name: Install dependencies + run: npm ci + + # Jest + Supertest. package.json "test" => "jest --runInBand". + # The test harness binds each suite to an isolated temp SQLite DB. + - name: Run tests + run: npm test + + docker-build: + name: Build Docker image + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Sanity-check that the production image builds. Not pushed anywhere. + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: finman:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..033cd8a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# syntax=docker/dockerfile:1 +# ============================================================================ +# FINMAN — multi-stage production image +# Stage 1 (deps): install PROD-only node_modules, rebuilding the sqlite3 +# native addon from source if no prebuilt binary matches. +# Stage 2 (runtime): slim image, copies node_modules + app, runs as non-root, +# persists the SQLite DB under /app/data (mount a volume!). +# ============================================================================ + +# ---- Stage 1: dependencies ------------------------------------------------ +FROM node:22-slim AS deps +WORKDIR /app + +# Toolchain needed to (re)build native addons like sqlite3 if a prebuilt +# binary is unavailable for this platform/glibc. Kept ONLY in the deps stage +# so the final image stays lean. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install prod deps against the lockfile for reproducible builds. +# npm rebuild sqlite3 forces a source build if the downloaded prebuilt +# doesn't load (belt-and-suspenders; usually the prebuilt just works). +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund \ + && npm rebuild sqlite3 --build-from-source --omit=dev || npm rebuild sqlite3 \ + && npm cache clean --force + +# ---- Stage 2: runtime ----------------------------------------------------- +FROM node:22-slim AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + PORT=3000 \ + DATABASE_PATH=/app/data/finance.db + +# wget is used by the HEALTHCHECK below; node:22-slim ships without it. +RUN apt-get update \ + && apt-get install -y --no-install-recommends wget \ + && rm -rf /var/lib/apt/lists/* + +# Bring in the already-built dependencies from the deps stage. +COPY --from=deps /app/node_modules ./node_modules + +# Copy the application source. .dockerignore keeps node_modules, the local +# data/ dir, mobile build output, tests and secrets out of the image. +COPY . . + +# Persisted SQLite database lives here. Create it up front and hand ownership +# to the non-root user so the app can write even on a fresh named volume. +RUN mkdir -p /app/data \ + && chown -R node:node /app + +# Drop privileges — never run the app as root. +USER node + +EXPOSE 3000 + +# Liveness: hit the real /api/health endpoint (provided by routes/health.js). +# Fails (exit 1) if the server is not answering with HTTP 200. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider "http://127.0.0.1:${PORT}/api/health" || exit 1 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..19bd8fb --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# FINMAN — Personal Finance Manager + +Self-hosted personal finance manager: accounts, transactions, budgets, savings +goals, debts, family/shared finances, investments, subscriptions, net-worth +tracking, CSV import, receipt OCR, and real bank API sync (Monobank / Revolut / +Tinkoff). Node/Express + SQLite + a vanilla-JS frontend (no build step), with an +optional AI assistant and Stripe-based subscription tiers. + +--- + +## Quick start + +### Option A — Docker (recommended, persistent DB) + +```bash +cp .env.example .env # then edit: set JWT_SECRET / SESSION_SECRET / ENCRYPTION_KEY +docker compose up -d --build # build image + start detached +curl http://localhost:3000/api/health +``` + +The SQLite database is stored on the named Docker volume **`finman-data`** +(mounted at `/app/data`), so it survives restarts, `docker compose down`, and +image rebuilds. + +One-liner build + deploy + smoke test: + +```bash +./scripts/deploy.sh +``` + +Stop / remove (DB volume is kept): + +```bash +docker compose down # keep data +docker compose down -v # ALSO delete the finman-data volume (wipes the DB!) +``` + +### Option B — Local development + +Requires **Node.js 22+**. + +```bash +cp .env.example .env # dev secrets auto-generate if unset (NODE_ENV != production) +npm install +npm run dev # nodemon, http://localhost:3000 +# or: npm start # plain node server.js +``` + +--- + +## Environment variables + +Copy `.env.example` to `.env`. In **production** (`NODE_ENV=production`) the +secrets are **required** — the app fails fast on boot if they are missing. In +development they auto-generate (with a warning). + +| Variable | Required | Purpose | +|---|---|---| +| `PORT` | no (default `3000`) | HTTP port the app listens on. | +| `NODE_ENV` | no (default `development`) | `development` or `production`. Production enforces real secrets. | +| `JWT_SECRET` | **prod** | Signing secret for Passport-JWT auth tokens. | +| `SESSION_SECRET` | **prod** | `express-session` cookie secret. | +| `ENCRYPTION_KEY` | **prod** | AES-256 key (64 hex chars) for encrypting stored bank tokens. | +| `DATABASE_PATH` | no | SQLite file path. Default `./data/finance.db`; Docker forces `/app/data/finance.db`. | +| `CORS_ORIGIN` | no | Allowed browser origin in production (set to your domain). | +| `LOG_LEVEL` | no | pino level (`info` default). | +| `AI_PROVIDER` | no | `anthropic` \| `openai` \| `ollama` (default `anthropic`). | +| `AI_API_KEY` | for AI | API key for the AI provider. Without it, AI endpoints return `503 AI_NOT_CONFIGURED`. | +| `AI_MODEL` | no | Model id (e.g. `claude-3-5-sonnet-latest`, `gpt-4o-mini`). | +| `AI_BASE_URL` | no | Override the provider base URL (e.g. Ollama / gateway). | +| `SYNC_ENABLED` | for sync | `true` to enable scheduled bank auto-sync. | +| `STRIPE_SECRET_KEY` | for billing | Stripe secret key for the subscription/billing flow. | +| `STRIPE_WEBHOOK_SECRET` | for billing | Stripe webhook signing secret (`whsec_...`). | + +Generate a strong secret: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +--- + +## Tests + +Jest + Supertest. Each suite runs against an isolated temporary SQLite database. + +```bash +npm test # jest --runInBand +npm run test:watch +``` + +CI (`.github/workflows/ci.yml`) runs `npm ci` + `npm test` on Node 22 for every +push/PR, then verifies the Docker image builds. + +--- + +## Architecture + +``` +Browser (vanilla JS, public/) ──HTTP/JSON──► Express app (server.js) + │ + Passport-JWT auth ───────┤ + │ + Route modules (routes/*.js) ──► Services (services/*.js) + │ + db helpers (db/database.js) + │ + SQLite file (data/finance.db) +``` + +- **Backend:** Node.js + Express. Security via `helmet`, `cors`, rate limiting, + and `express-session`. Structured logging via `pino` / `pino-http`. +- **Auth:** Passport with a JWT strategy (`Authorization: Bearer `). +- **Database:** a single SQLite file (`data/finance.db`). Schema is created on + boot by `initDatabase()`, then additive migrations run via `lib/migrate.js` + (`migrations/*.js`). Access through the promise helpers `query` / `get` / `run`. +- **Shared libs:** `lib/money.js` (float-safe money math), `lib/respond.js` + (uniform `{success,data}` / `{success,error}` envelopes), `lib/ai/provider.js` + (provider-agnostic AI client), and middleware (`error.js`, `authorize.js`, + `requireTier.js`). +- **Frontend:** static HTML/CSS/JS in `public/` — no build step; scripts are + loaded via ` + + @@ -49,6 +51,8 @@ + +