Stack overview
- Frontend: Next.js 15 App Router, React 19, TypeScript strict, Tailwind CSS v3
- Engine: Pure-TypeScript package (
@swiss-tax/engine) with Decimal.js for money math, no I/O — embeddable in any runtime - Database: PostgreSQL 16 via Drizzle ORM (idempotent migrations + seed)
- Auth: NextAuth v5 (Auth.js) with Resend magic links
- Payments: Stripe Checkout (Card + TWINT)
- AI extraction: Anthropic Claude Sonnet (PDF → structured fields, AHV-redacted)
- Encryption: AES-256-GCM with per-file 12-byte nonce
- Internationalisation: next-intl (EN / DE / FR)
- Build: pnpm workspaces + Turborepo
- Hosting: Self-hosted Docker on Hetzner Cloud (Germany), Tailscale-bound during dev
Repository layout
swiss-tax/
├── apps/
│ └── web/ — Next.js app (UI + API routes)
│ ├── src/
│ │ ├── app/ — App-router pages (60 SSG/SSR routes)
│ │ ├── components/ — shared React
│ │ ├── lib/ — atlas helpers, fonts
│ │ └── server/ — db, auth, gate, debug
│ └── drizzle/ — DB migrations
├── packages/
│ └── tax-engine/ — Pure-TS engine
│ ├── src/
│ │ ├── cantonal/ — 26 canton scales (verified)
│ │ ├── communal/ — bundled commune JSON
│ │ ├── core/ — compute, scale, levers
│ │ ├── federal/ — federal scale
│ │ ├── levers/ — LPP, 3a, combo, multi-year, move
│ │ ├── news/ — front-page ticker items
│ │ └── social/ — social-insurance constants
│ └── test/ — 57 Vitest tests
├── tooling/scripts/
│ ├── fetch-communes.ts — ingestion pipeline (CSV/PDF/XLS/JSON)
│ ├── build-canton-map.ts — TopoJSON → SVG paths
│ └── lib/pdf.ts — pdftotext wrapper
└── deploy/
├── entrypoint.sh — migrations + seed on container boot
├── sync.sh — rsync + docker compose up
└── cron-refresh.sh — yearly data refreshData flow — request lifecycle
- Browser: user lands on a route (e.g.
/commune/355). - Next.js standalone server (Docker container, port 3000): receives the request.
- Authentication (only for paid routes): the gate (
requirePaidUser) checks the session token and looks up the user in Postgres. - Computation: imports
@swiss-tax/engine, calls pure functions to compute tax / lever results. No external I/O during compute. - Data lookup: commune data comes from the bundled
communes.generated.json(loaded at module init); canton scales from per-canton TS files. Postgres is not on the compute path — it holds user profile, uploads, and a mirror of the same commune data for admin/SEO. - Rendering: server-side React renders the page; client-side hydration for interactive elements (commune search, canton-list filter, news ticker, calculator form).
- Response: HTML (for SSG/SSR routes), JSON (for API routes), or PNG (for OG images via
next/og).
Deployment topology
┌──────────────────────────────┐
Internet ──HTTPS─┤ optiqo.ch (DNS) │
│ ↓ │
│ Cloudflare (optional CDN) │
│ ↓ │
│ Hetzner Cloud Server (DE) │
│ ┌────────────────────────┐ │
│ │ Docker network │ │
│ │ ┌──────────────┐ │ │
│ │ │ swisstax-web │←─────┼─┼── Tailscale (dev)
│ │ │ (Next.js) │ │ │
│ │ └──────┬───────┘ │ │
│ │ │ TCP 5432 │ │
│ │ ┌──────▼───────┐ │ │
│ │ │ swisstax-db │ │ │
│ │ │ (Postgres16) │ │ │
│ │ └──────────────┘ │ │
│ └────────────────────────┘ │
│ ./data/uploads (bind mount)│
└──────────────────────────────┘Both containers are private. swisstax-web exposes port 3000 (mapped to host port 3010 bound to Tailscale IP100.86.112.23 during development; public 80/443 via Cloudflare in production).
Boot sequence
entrypoint.shchecksSTORAGE_KEYis set (fatal if not — at-rest encryption requires it).- Creates upload root with mode 700.
- Runs Drizzle migrations against Postgres (idempotent — tracks applied via
__drizzle_migrations). - Runs the seed script —
commune_ratetable is wiped + repopulated from the bundled JSON snapshot inside a single transaction. tax_newstable is upserted from the bundled news JSON.- Next.js server starts on port 3000.
- Hourly purge worker schedules — sweeps encrypted blobs older than 30 days.
Security model
The threat model assumes:
- Adversary with cold storage access: an attacker who steals a backup or a hard drive cannot decrypt uploaded documents — the master key is in process memory only.
- Adversary with database read access: structured extracted fields are stored separately from encrypted blobs. The
extracted_fieldsJSON contains only numbers (gross salary, BVG contribution); AHV/NAVS13 numbers, IBANs, addresses are explicitly redacted before persist. - Adversary with session-cookie theft: cookies are HTTP-only, secure, same-site Lax. Sessions are revocable from the dashboard.
- Insider with server access: log lines do not capture form bodies; secrets come from environment variables (Docker compose) and are not echoed.
Caching
- SSG: 60 routes pre-rendered at build time (home, atlas, 26 canton pages, ~2000 commune pages with
generateStaticParams). - OG images: generated on first request, cached by Next.js's
opengraph-imageroute until next deploy. - CSS/JS: hashed asset names with 1-year cache headers from Next.js.
- Engine JSON snapshot: bundled at build time (12 KB canton paths, ~250 KB commune data).
Observability
Container logs go to Docker's stdout/stderr (collected by standard Docker logging). Migrations, seed runs, and the hourly purge worker write to the same stream with prefixes.
No external APM / logging service is wired today. For production we'd add Sentry for errors and a simple Prometheus exporter for uptime — both are on the “Sprint 4” backlog.
Performance targets
- p50 SSG hit: under 100 ms TTFB (no DB; just file read + render)
- p50 calculator: under 200 ms (engine compute + JSON serialise)
- p50 OG image: under 500 ms (font load + render + PNG encode)
- Total bundle (first load): 105 KB shared + 0-15 KB per route